diff --git a/docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md b/docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md new file mode 100644 index 0000000..8a06681 --- /dev/null +++ b/docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md @@ -0,0 +1,33 @@ +# Issue #310 — Host transition idempotency and error catalog + +## Scope + +This artifact hardens the two host-owned scoreboard exits in the canonical gameplay flow: + +- `POST /lobby/sessions/{code}/rounds/next` +- `POST /lobby/sessions/{code}/finish` + +The goal is retry-safe host behavior when the scoreboard transition already succeeded server-side but the client retries because of a duplicate click, timeout, or lost response. + +## Transition contract + +| Endpoint | First valid transition | Idempotent replay state | Replay result | Broadcast behavior | Still-invalid states | +|---|---|---|---|---|---| +| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard -> lie` | `lie` with persisted current-round bootstrap (`RoundConfig` + `RoundQuestion`) | `200 OK` with the same canonical next-round payload shape | `phase.lie_started` fires only on the first transition | `lobby`, `guess`, `reveal`, `finished` → `next_round_invalid_phase` | +| `POST /lobby/sessions/{code}/finish` | `scoreboard -> finished` | `finished` | `200 OK` with the same final leaderboard payload shape | `phase.game_over` fires only on the first transition | `lobby`, `lie`, `guess`, `reveal` → `finish_game_invalid_phase` | + +## Error catalog notes + +No new backend error codes were introduced for this slice. + +The contract change is behavioral: + +- `next_round_invalid_phase` now means the session is in a phase where the scoreboard → next-round transition has **not** already been completed, or the expected bootstrap artifact for the already-started round is missing. +- `finish_game_invalid_phase` now means the session is in a phase where the scoreboard → finish transition has **not** already been completed. +- Successful replays are returned as normal `200 OK` canonical responses instead of phase errors. + +## Acceptance evidence + +- Repeated `rounds/next` calls after a successful scoreboard exit return the same canonical lie/bootstrap payload without incrementing the round twice. +- Repeated `finish` calls after a successful scoreboard exit return the same finished leaderboard payload without rebroadcasting game-over. +- Wrong-phase calls outside those replay states still return the existing shared error codes. diff --git a/fupogfakta/migrations/0007_roundconfig_started_from_scoreboard.py b/fupogfakta/migrations/0007_roundconfig_started_from_scoreboard.py new file mode 100644 index 0000000..bfa0e77 --- /dev/null +++ b/fupogfakta/migrations/0007_roundconfig_started_from_scoreboard.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-03-17 08:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fupogfakta', '0006_merge_20260315_1249'), + ] + + operations = [ + migrations.AddField( + model_name='roundconfig', + name='started_from_scoreboard', + field=models.BooleanField(default=False), + ), + ] diff --git a/fupogfakta/models.py b/fupogfakta/models.py index 8670349..3668e21 100644 --- a/fupogfakta/models.py +++ b/fupogfakta/models.py @@ -83,6 +83,7 @@ class RoundConfig(models.Model): points_bluff = models.IntegerField(default=2) lie_seconds = models.PositiveIntegerField(default=45) guess_seconds = models.PositiveIntegerField(default=30) + started_from_scoreboard = models.BooleanField(default=False) class Meta: unique_together = (("session", "number"),) diff --git a/fupogfakta/payloads.py b/fupogfakta/payloads.py index 9024e6b..15fce65 100644 --- a/fupogfakta/payloads.py +++ b/fupogfakta/payloads.py @@ -13,6 +13,19 @@ def build_player_ref(player: Player | None) -> dict | None: } +def build_round_question_payload(round_question: RoundQuestion | None) -> dict | None: + if round_question is None: + return None + + return { + "id": round_question.id, + "round_number": round_question.round_number, + "prompt": round_question.question.prompt, + "shown_at": round_question.shown_at.isoformat(), + "answers": [{"text": text} for text in (round_question.mixed_answers or [])], + } + + def build_reveal_payload(round_question: RoundQuestion | None) -> dict | None: if round_question is None: return None @@ -68,3 +81,195 @@ def build_lie_started_payload(session: GameSession, round_config: RoundConfig, r "lie_deadline_at": lie_deadline_at.isoformat(), "lie_seconds": round_config.lie_seconds, } + + +def build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict: + status = session.status + in_lobby = status == GameSession.Status.LOBBY + in_lie = status == GameSession.Status.LIE + in_guess = status == GameSession.Status.GUESS + in_scoreboard = status == GameSession.Status.SCOREBOARD + in_finished = status == GameSession.Status.FINISHED + + min_players_reached = players_count >= 3 + max_players_allowed = players_count <= 5 + + return { + "status": status, + "current_phase": status, + "round_number": session.current_round, + "players_count": players_count, + "constraints": { + "min_players_to_start": 3, + "max_players_mvp": 5, + "min_players_reached": min_players_reached, + "max_players_allowed": max_players_allowed, + }, + "readiness": { + "question_ready": has_round_question, + "scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}, + "can_advance_to_next_round": in_scoreboard, + }, + "host": { + "can_start_round": in_lobby and min_players_reached and max_players_allowed, + "can_show_question": False, + "can_mix_answers": False, + "can_calculate_scores": False, + "can_reveal_scoreboard": False, + "can_start_next_round": in_scoreboard, + "can_finish_game": in_scoreboard, + }, + "player": { + "can_join": status in { + GameSession.Status.LOBBY, + GameSession.Status.LIE, + GameSession.Status.GUESS, + GameSession.Status.REVEAL, + GameSession.Status.SCOREBOARD, + }, + "can_submit_lie": in_lie and has_round_question, + "can_submit_guess": in_guess and has_round_question, + "can_view_final_result": in_finished, + }, + } + + +def build_session_detail_gameplay_payload( + session: GameSession, + *, + current_round_question: RoundQuestion | None, + players_count: int, +) -> dict: + return { + "round_question": build_round_question_payload(current_round_question), + "reveal": build_reveal_payload(current_round_question) + if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question + else None, + "scoreboard": build_scoreboard_phase_event(session)["payload"]["leaderboard"] + if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED} + else None, + "phase_view_model": build_phase_view_model( + session, + players_count=players_count, + has_round_question=bool(current_round_question), + ), + } + + +def build_start_round_response( + session: GameSession, + round_config: RoundConfig, + round_question: RoundQuestion, +) -> dict: + lie_started_payload = build_lie_started_payload(session, round_config, round_question) + return { + "session": { + "code": session.code, + "status": session.status, + "current_round": session.current_round, + }, + "round": { + "number": round_config.number, + "category": { + "slug": round_config.category.slug, + "name": round_config.category.name, + }, + }, + "round_question": { + "id": round_question.id, + "prompt": round_question.question.prompt, + "round_number": round_question.round_number, + "shown_at": round_question.shown_at.isoformat(), + "lie_deadline_at": lie_started_payload["lie_deadline_at"], + }, + "config": { + "lie_seconds": round_config.lie_seconds, + }, + } + + +def build_question_shown_payload(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict: + return { + "round_question_id": round_question.id, + "prompt": round_question.question.prompt, + "shown_at": round_question.shown_at.isoformat(), + "lie_deadline_at": lie_deadline_at, + "lie_seconds": lie_seconds, + } + + +def build_question_shown_response(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict: + return { + "round_question": { + "id": round_question.id, + "prompt": round_question.question.prompt, + "round_number": round_question.round_number, + "shown_at": round_question.shown_at.isoformat(), + "lie_deadline_at": lie_deadline_at, + }, + "config": { + "lie_seconds": lie_seconds, + }, + } + + +def build_start_next_round_response( + session: GameSession, + round_config: RoundConfig, + round_question: RoundQuestion, +) -> dict: + return build_start_round_response(session, round_config, round_question) + + +def build_start_next_round_phase_event( + session: GameSession, + round_config: RoundConfig, + round_question: RoundQuestion, +) -> dict: + return { + "name": "phase.lie_started", + "payload": build_lie_started_payload(session, round_config, round_question), + } + + +def build_scoreboard_phase_event(session: GameSession, leaderboard: list[dict] | None = None) -> dict: + return { + "name": "phase.scoreboard", + "payload": { + "leaderboard": leaderboard if leaderboard is not None else build_leaderboard(session), + "current_round": session.current_round, + }, + } + + +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: + leaderboard = build_leaderboard(session) + winner = leaderboard[0] if leaderboard else None + return { + "name": "phase.game_over", + "payload": {"winner": winner, "leaderboard": leaderboard}, + } + + +def build_finish_game_response(session: GameSession) -> dict: + finish_event = build_finish_game_phase_event(session) + return { + "session": { + "code": session.code, + "status": GameSession.Status.FINISHED, + "current_round": session.current_round, + }, + "winner": finish_event["payload"]["winner"], + "leaderboard": finish_event["payload"]["leaderboard"], + } diff --git a/fupogfakta/services.py b/fupogfakta/services.py index 562d2bb..7ef9a6f 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -1,11 +1,59 @@ import random +from datetime import timedelta +from dataclasses import dataclass +from typing import Any -from .models import GameSession, Player, Question, RoundConfig, RoundQuestion, ScoreEvent +from django.db import transaction +from django.utils import timezone + +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, +) -def get_current_round_question(session: GameSession) -> RoundQuestion | None: +@dataclass(frozen=True) +class RoundTransitionResult: + session: GameSession + round_config: RoundConfig + round_question: RoundQuestion + should_broadcast: bool + response_payload: dict[str, Any] + phase_event_name: str | None = None + phase_event_payload: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class FinishGameResult: + session: GameSession + should_broadcast: bool + response_payload: dict[str, Any] + phase_event_name: str | None = None + phase_event_payload: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class ScoreboardTransitionResult: + session: GameSession + leaderboard: list[dict] + 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_round_question(session: GameSession, round_number: int) -> RoundQuestion | None: return ( - RoundQuestion.objects.filter(session=session, round_number=session.current_round) + RoundQuestion.objects.filter(session=session, round_number=round_number) .select_related("question") .order_by("-id") .first() @@ -13,9 +61,37 @@ def get_current_round_question(session: GameSession) -> RoundQuestion | None: -def select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion: - existing_round_question = get_current_round_question(session) - if existing_round_question is not None: +def get_current_round_question(session: GameSession) -> RoundQuestion | None: + return get_round_question(session, session.current_round) + + + +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() + + update_fields: list[str] = [] + if round_question.mixed_answers: + round_question.mixed_answers = [] + update_fields.append("mixed_answers") + + round_question.shown_at = timezone.now() + update_fields.append("shown_at") + + round_question.save(update_fields=update_fields) + return round_question + + + +def select_round_question( + session: GameSession, + round_config: RoundConfig, + *, + round_number: int | None = None, +) -> RoundQuestion: + target_round_number = session.current_round if round_number is None else round_number + existing_round_question = get_round_question(session, target_round_number) + if existing_round_question is not None and existing_round_question.question.category_id == round_config.category_id: return existing_round_question used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True) @@ -28,9 +104,15 @@ def select_round_question(session: GameSession, round_config: RoundConfig) -> Ro raise ValueError("no_available_questions") question = random.choice(list(available_questions)) + if existing_round_question is not None: + existing_round_question.question = question + existing_round_question.correct_answer = question.correct_answer + existing_round_question.save(update_fields=["question", "correct_answer"]) + return existing_round_question + return RoundQuestion.objects.create( session=session, - round_number=session.current_round, + round_number=target_round_number, question=question, correct_answer=question.correct_answer, ) @@ -61,6 +143,288 @@ 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) + next_round_config = None + round_question = None + should_broadcast = False + + phase_event_name = None + phase_event_payload = 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: + raise ValueError("round_config_missing") + + next_round_number = locked_session.current_round + 1 + next_round_config, _created = RoundConfig.objects.get_or_create( + session=locked_session, + number=next_round_number, + defaults={ + "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, + }, + ) + round_config_update_fields: list[str] = [] + if next_round_config.category_id != previous_round_config.category_id: + next_round_config.category = previous_round_config.category + round_config_update_fields.append("category") + if next_round_config.lie_seconds != previous_round_config.lie_seconds: + next_round_config.lie_seconds = previous_round_config.lie_seconds + round_config_update_fields.append("lie_seconds") + if next_round_config.guess_seconds != previous_round_config.guess_seconds: + next_round_config.guess_seconds = previous_round_config.guess_seconds + round_config_update_fields.append("guess_seconds") + if next_round_config.points_correct != previous_round_config.points_correct: + next_round_config.points_correct = previous_round_config.points_correct + round_config_update_fields.append("points_correct") + if next_round_config.points_bluff != previous_round_config.points_bluff: + next_round_config.points_bluff = previous_round_config.points_bluff + round_config_update_fields.append("points_bluff") + if not next_round_config.started_from_scoreboard: + next_round_config.started_from_scoreboard = True + round_config_update_fields.append("started_from_scoreboard") + if round_config_update_fields: + next_round_config.save(update_fields=round_config_update_fields) + + locked_session.current_round = next_round_number + + round_question = reset_round_question_bootstrap_state( + select_round_question(locked_session, next_round_config, round_number=next_round_number) + ) + + locked_session.status = GameSession.Status.LIE + locked_session.save(update_fields=["current_round", "status"]) + 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: + 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, + response_payload=build_start_next_round_response( + locked_session, + next_round_config, + round_question, + ), + phase_event_name=phase_event_name, + phase_event_payload=phase_event_payload, + ) + + + +def finish_game(session: GameSession) -> FinishGameResult: + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + should_broadcast = False + phase_event_name = None + phase_event_payload = None + + if locked_session.status == GameSession.Status.SCOREBOARD: + locked_session.status = GameSession.Status.FINISHED + locked_session.save(update_fields=["status"]) + 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: + raise ValueError("finish_game_invalid_phase") + + return FinishGameResult( + session=locked_session, + should_broadcast=should_broadcast, + response_payload=build_finish_game_response(locked_session), + phase_event_name=phase_event_name, + phase_event_payload=phase_event_payload, + ) + + + +def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionResult: + if session.status != GameSession.Status.REVEAL: + leaderboard = list( + Player.objects.filter(session=session) + .order_by("-score", "nickname") + .values("id", "nickname", "score") + ) + 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) + if current_round_question is None: + leaderboard = list( + Player.objects.filter(session=session) + .order_by("-score", "nickname") + .values("id", "nickname", "score") + ) + 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() + guess_count = Guess.objects.filter(round_question=current_round_question).count() + has_score_events = ScoreEvent.objects.filter( + session=session, + meta__round_question_id=current_round_question.id, + ).exists() + reveal_is_resolved = has_score_events or (players_count > 0 and guess_count >= players_count) + if not reveal_is_resolved: + leaderboard = list( + Player.objects.filter(session=session) + .order_by("-score", "nickname") + .values("id", "nickname", "score") + ) + return ScoreboardTransitionResult( + session=session, + leaderboard=leaderboard, + should_broadcast=False, + response_payload=build_reveal_scoreboard_response(session, leaderboard), + ) + + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + if locked_session.status != GameSession.Status.REVEAL: + scoreboard_session = locked_session + should_broadcast = False + else: + locked_session.status = GameSession.Status.SCOREBOARD + locked_session.save(update_fields=["status"]) + scoreboard_session = locked_session + should_broadcast = True + + leaderboard = list( + Player.objects.filter(session=scoreboard_session) + .order_by("-score", "nickname") + .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( + session=scoreboard_session, + leaderboard=leaderboard, + 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, + ) + + + def resolve_scores( session: GameSession, round_question: RoundQuestion, diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index d64e10c..6978ad5 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -1,11 +1,27 @@ +from datetime import timedelta from unittest.mock import patch from django.contrib.auth import get_user_model from django.test import TestCase +from django.utils import timezone 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.services import get_current_round_question, prepare_mixed_answers, resolve_scores, select_round_question +from fupogfakta.payloads import ( + build_lie_started_payload, + build_phase_view_model, + build_reveal_payload, + build_round_question_payload, + build_session_detail_gameplay_payload, +) +from fupogfakta.services import ( + finish_game, + get_current_round_question, + prepare_mixed_answers, + promote_reveal_to_scoreboard, + resolve_scores, + select_round_question, + start_next_round, +) User = get_user_model() @@ -63,6 +79,232 @@ class FupOgFaktaExtractionSliceTests(TestCase): round_question.refresh_from_db() 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_start_next_round_refreshes_shown_at_for_reused_bootstrap_question(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + stale_shown_at = timezone.now() - timedelta(minutes=10) + stale_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.question_two, + correct_answer=self.question_two.correct_answer, + shown_at=stale_shown_at, + mixed_answers=["Stale truth", "Stale lie"], + ) + LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Stale lie") + Guess.objects.create( + round_question=stale_round_question, + player=self.bob, + selected_text="Stale truth", + is_correct=True, + ) + + before_transition = timezone.now() + result = start_next_round(self.session) + after_transition = timezone.now() + + stale_round_question.refresh_from_db() + self.assertEqual(result.round_question.id, stale_round_question.id) + self.assertGreaterEqual(stale_round_question.shown_at, before_transition) + self.assertLessEqual(stale_round_question.shown_at, after_transition) + self.assertNotEqual(stale_round_question.shown_at, stale_shown_at) + self.assertEqual(result.response_payload["round_question"]["shown_at"], stale_round_question.shown_at.isoformat()) + expected_deadline = stale_round_question.shown_at + timedelta(seconds=result.round_config.lie_seconds) + self.assertEqual(result.response_payload["round_question"]["lie_deadline_at"], expected_deadline.isoformat()) + self.assertGreater(expected_deadline, before_transition) + self.assertEqual(stale_round_question.mixed_answers, []) + self.assertEqual(stale_round_question.lies.count(), 0) + self.assertEqual(stale_round_question.guesses.count(), 0) + + def test_start_next_round_reuses_existing_bootstrap_round_config_with_fresh_canonical_values(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + stale_category = Category.objects.create(name="Sport", slug="sport", is_active=True) + stale_round_config = RoundConfig.objects.create( + session=self.session, + number=2, + category=stale_category, + lie_seconds=12, + guess_seconds=18, + points_correct=9, + points_bluff=7, + started_from_scoreboard=False, + ) + stale_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.question_two, + correct_answer=self.question_two.correct_answer, + shown_at=timezone.now() - timedelta(minutes=10), + mixed_answers=["Stale truth"], + ) + + result = start_next_round(self.session) + + stale_round_config.refresh_from_db() + stale_round_question.refresh_from_db() + self.assertEqual(result.round_config.id, stale_round_config.id) + self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) + self.assertEqual(stale_round_config.category_id, self.round_config.category_id) + self.assertEqual(stale_round_config.lie_seconds, self.round_config.lie_seconds) + self.assertEqual(stale_round_config.guess_seconds, self.round_config.guess_seconds) + self.assertEqual(stale_round_config.points_correct, self.round_config.points_correct) + self.assertEqual(stale_round_config.points_bluff, self.round_config.points_bluff) + self.assertTrue(stale_round_config.started_from_scoreboard) + self.assertEqual(result.round_question.id, stale_round_question.id) + self.assertEqual(stale_round_question.mixed_answers, []) + + def test_start_next_round_repairs_reused_bootstrap_question_when_category_drifted(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question_one, + correct_answer=self.question_one.correct_answer, + ) + stale_category = Category.objects.create(name="Sport drift", slug="sport-drift", is_active=True) + stale_question = Question.objects.create( + category=stale_category, + prompt="Hvem vandt EM i 1992?", + correct_answer="Danmark", + is_active=True, + ) + stale_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=stale_question, + correct_answer=stale_question.correct_answer, + shown_at=timezone.now() - timedelta(minutes=10), + mixed_answers=["Stale truth", "Stale lie"], + ) + LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Tyskland") + Guess.objects.create( + round_question=stale_round_question, + player=self.bob, + selected_text="Stale truth", + is_correct=True, + ) + + result = start_next_round(self.session) + + stale_round_question.refresh_from_db() + self.assertEqual(result.round_question.id, stale_round_question.id) + self.assertEqual(stale_round_question.question.category_id, self.round_config.category_id) + self.assertEqual(stale_round_question.question_id, self.question_two.id) + self.assertEqual(stale_round_question.correct_answer, self.question_two.correct_answer) + self.assertEqual(stale_round_question.mixed_answers, []) + self.assertEqual(stale_round_question.lies.count(), 0) + self.assertEqual(stale_round_question.guesses.count(), 0) + + def test_start_next_round_does_not_reuse_previous_round_question_when_category_matches(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + previous_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question_one, + correct_answer=self.question_one.correct_answer, + mixed_answers=["1989", "1991"], + ) + LieAnswer.objects.create(round_question=previous_round_question, player=self.alice, text="1991") + Guess.objects.create( + round_question=previous_round_question, + player=self.bob, + selected_text="1991", + is_correct=False, + fooled_player=self.alice, + ) + + result = start_next_round(self.session) + + previous_round_question.refresh_from_db() + self.session.refresh_from_db() + self.assertEqual(self.session.current_round, 2) + self.assertEqual(result.round_question.round_number, 2) + self.assertNotEqual(result.round_question.id, previous_round_question.id) + self.assertEqual(result.round_question.question_id, self.question_two.id) + self.assertEqual(previous_round_question.round_number, 1) + self.assertEqual(previous_round_question.question_id, self.question_one.id) + self.assertEqual(previous_round_question.mixed_answers, ["1989", "1991"]) + self.assertEqual(previous_round_question.lies.count(), 1) + self.assertEqual(previous_round_question.guesses.count(), 1) + + 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_promote_reveal_to_scoreboard_moves_transition_into_service(self): + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question_one, + correct_answer=self.question_one.correct_answer, + ) + self.session.status = GameSession.Status.REVEAL + self.session.save(update_fields=["status"]) + + LieAnswer.objects.create(round_question=round_question, player=self.alice, text="Elbil") + Guess.objects.create( + round_question=round_question, + player=self.bob, + selected_text="Elbil", + is_correct=False, + fooled_player=self.alice, + ) + ScoreEvent.objects.create( + session=self.session, + player=self.alice, + delta=5, + reason="bluff_success", + meta={"round_question_id": round_question.id}, + ) + self.alice.score = 5 + self.alice.save(update_fields=["score"]) + + result = promote_reveal_to_scoreboard(self.session) + + self.session.refresh_from_db() + self.assertTrue(result.should_broadcast) + self.assertEqual(result.session.status, GameSession.Status.SCOREBOARD) + self.assertEqual(result.leaderboard[0]["nickname"], self.alice.nickname) + def test_resolve_scores_applies_correct_and_bluff_points(self): round_question = RoundQuestion.objects.create( session=self.session, @@ -117,11 +359,52 @@ class FupOgFaktaExtractionSliceTests(TestCase): fooled_player=self.bob, ) + round_question_payload = build_round_question_payload(round_question) lie_payload = build_lie_started_payload(self.session, self.round_config, round_question) reveal_payload = build_reveal_payload(round_question) + phase_view_model = build_phase_view_model( + self.session, + players_count=3, + has_round_question=True, + ) + self.assertEqual(round_question_payload["prompt"], self.question_one.prompt) + self.assertEqual(round_question_payload["answers"], []) self.assertEqual(lie_payload["category"], {"slug": self.category.slug, "name": self.category.name}) self.assertEqual(lie_payload["round_question_id"], round_question.id) self.assertEqual(reveal_payload["correct_answer"], "1989") self.assertEqual(reveal_payload["lies"][0]["player_id"], lie.player_id) self.assertEqual(reveal_payload["guesses"][0]["fooled_player_nickname"], self.bob.nickname) + self.assertTrue(phase_view_model["host"]["can_start_round"]) + self.assertFalse(phase_view_model["host"]["can_finish_game"]) + + def test_build_session_detail_gameplay_payload_keeps_session_detail_semantics_in_cartridge(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question_one, + correct_answer=self.question_one.correct_answer, + ) + lie = LieAnswer.objects.create(round_question=round_question, player=self.bob, text="1991") + Guess.objects.create( + round_question=round_question, + player=self.alice, + selected_text="1991", + is_correct=False, + fooled_player=self.bob, + ) + + gameplay_payload = build_session_detail_gameplay_payload( + self.session, + current_round_question=round_question, + players_count=3, + ) + + self.assertEqual(gameplay_payload["round_question"]["id"], round_question.id) + self.assertEqual(gameplay_payload["reveal"]["lies"][0]["player_id"], lie.player_id) + self.assertEqual(gameplay_payload["scoreboard"], [{"id": self.alice.id, "nickname": self.alice.nickname, "score": self.alice.score}, {"id": self.bob.id, "nickname": self.bob.nickname, "score": self.bob.score}, {"id": self.clara.id, "nickname": self.clara.nickname, "score": self.clara.score}]) + self.assertEqual(gameplay_payload["phase_view_model"]["status"], GameSession.Status.SCOREBOARD) + self.assertTrue(gameplay_payload["phase_view_model"]["host"]["can_start_next_round"]) + self.assertTrue(gameplay_payload["phase_view_model"]["host"]["can_finish_game"]) diff --git a/lobby/tests.py b/lobby/tests.py index 5c3e8ab..6e5140b 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1,3 +1,4 @@ +import inspect import json import tempfile from datetime import timedelta @@ -10,6 +11,7 @@ from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone +from fupogfakta import payloads as gameplay_payloads, services as gameplay_services from fupogfakta.models import ( Category, GameSession, @@ -21,11 +23,343 @@ from fupogfakta.models import ( RoundQuestion, ScoreEvent, ) +from lobby import views as lobby_views from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale User = get_user_model() +class LobbyGameplayExtractionTests(TestCase): + def setUp(self): + self.host = User.objects.create_user(username="extract_host", password="secret123") + self.client.login(username="extract_host", password="secret123") + self.session = GameSession.objects.create( + host=self.host, + code="EXTR42", + status=GameSession.Status.SCOREBOARD, + ) + self.category = Category.objects.create(name="Historie", slug="historie-extract", is_active=True) + self.round_config = RoundConfig.objects.create( + session=self.session, + number=1, + category=self.category, + ) + self.question = Question.objects.create( + category=self.category, + prompt="Hvornår faldt muren?", + correct_answer="1989", + is_active=True, + ) + + def test_lobby_views_use_extracted_gameplay_helpers(self): + self.assertIs(lobby_views._get_current_round_question, gameplay_services.get_current_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._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_session_detail_gameplay_payload, gameplay_payloads.build_session_detail_gameplay_payload) + self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) + + def test_start_round_view_source_stays_http_thin(self): + source = inspect.getsource(inspect.unwrap(lobby_views.start_round)) + + 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("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)) + + self.assertIn("transition = _start_next_round(session)", source) + self.assertNotIn("RoundConfig", source) + self.assertNotIn("RoundQuestion", source) + self.assertNotIn("build_start_next_round_response", source) + self.assertNotIn("build_start_next_round_phase_event", source) + + def test_finish_game_view_source_stays_http_thin(self): + source = inspect.getsource(inspect.unwrap(lobby_views.finish_game)) + + self.assertIn("transition = _finish_game(session)", source) + self.assertNotIn("RoundConfig", source) + self.assertNotIn("RoundQuestion", source) + self.assertNotIn("build_finish_game_response", source) + self.assertNotIn("build_finish_game_phase_event", source) + + def test_reveal_scoreboard_view_source_stays_http_thin(self): + source = inspect.getsource(inspect.unwrap(lobby_views.reveal_scoreboard)) + + self.assertIn("transition = _promote_reveal_to_scoreboard(session)", source) + self.assertNotIn("Player.objects.filter(session=session)", source) + self.assertNotIn("ScoreEvent.objects.filter", source) + self.assertNotIn("build_reveal_scoreboard_response", source) + self.assertNotIn("build_scoreboard_phase_event", source) + + def test_issue_310_transition_views_keep_gameplay_logic_out_of_lobby(self): + transition_sources = { + "reveal_scoreboard": inspect.getsource(inspect.unwrap(lobby_views.reveal_scoreboard)), + "start_next_round": inspect.getsource(inspect.unwrap(lobby_views.start_next_round)), + "finish_game": inspect.getsource(inspect.unwrap(lobby_views.finish_game)), + } + + forbidden_snippets = ( + "RoundConfig.objects.get_or_create(", + "RoundConfig.objects.create(", + "RoundQuestion.objects.create(", + "select_round_question(", + "reset_round_question_bootstrap_state(", + "session.current_round =", + "session.status = GameSession.Status.LIE", + "session.status = GameSession.Status.SCOREBOARD", + "session.status = GameSession.Status.FINISHED", + "build_start_next_round_response(", + "build_start_next_round_phase_event(", + "build_finish_game_response(", + "build_finish_game_phase_event(", + "build_reveal_scoreboard_response(", + "build_scoreboard_phase_event(", + "ScoreEvent.objects.filter(", + "Player.objects.filter(", + ) + + for view_name, source in transition_sources.items(): + for snippet in forbidden_snippets: + self.assertNotIn(snippet, source, msg=f"{view_name} leaked gameplay snippet: {snippet}") + + def test_session_detail_view_source_stays_http_thin(self): + source = inspect.getsource(inspect.unwrap(lobby_views.session_detail)) + + self.assertIn("session = _maybe_promote_reveal_to_scoreboard(session)", source) + self.assertIn("current_round_question = _get_current_round_question(session)", source) + self.assertIn("gameplay_payload = _build_session_detail_gameplay_payload(", source) + self.assertIn("**gameplay_payload", source) + self.assertNotIn("build_round_question_payload", source) + self.assertNotIn("build_phase_view_model", source) + self.assertNotIn("build_reveal_payload", source) + self.assertNotIn("build_scoreboard_phase_event(session)[\"payload\"][\"leaderboard\"]", source) + self.assertNotIn("lies.select_related", source) + self.assertNotIn("guesses.select_related", source) + self.assertNotIn("Player.objects.filter(session=session)", source) + self.assertNotIn("leaderboard =", 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( + self, + mock_start_next_round, + mock_sync_broadcast_phase_event, + ): + next_round_config = RoundConfig.objects.create( + session=self.session, + number=2, + category=self.category, + started_from_scoreboard=True, + ) + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.question, + correct_answer=self.question.correct_answer, + ) + transition = gameplay_services.RoundTransitionResult( + session=self.session, + round_config=next_round_config, + round_question=round_question, + should_broadcast=True, + response_payload={"ok": True}, + phase_event_name="phase.lie_started", + phase_event_payload={"round_question_id": round_question.id}, + ) + mock_start_next_round.return_value = transition + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + mock_start_next_round.assert_called_once_with(self.session) + mock_sync_broadcast_phase_event.assert_called_once_with( + self.session.code, + "phase.lie_started", + {"round_question_id": round_question.id}, + ) + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._finish_game") + def test_finish_game_view_delegates_transition_to_service( + self, + mock_finish_game, + mock_sync_broadcast_phase_event, + ): + finished_session = GameSession.objects.get(pk=self.session.pk) + finished_session.status = GameSession.Status.FINISHED + transition = gameplay_services.FinishGameResult( + session=finished_session, + should_broadcast=True, + response_payload={"ok": True}, + phase_event_name="phase.game_over", + phase_event_payload={"winner": None, "leaderboard": []}, + ) + mock_finish_game.return_value = transition + + response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + mock_finish_game.assert_called_once_with(self.session) + mock_sync_broadcast_phase_event.assert_called_once_with( + self.session.code, + "phase.game_over", + {"winner": None, "leaderboard": []}, + ) + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._start_next_round") + def test_start_next_round_view_skips_broadcast_on_service_replay( + self, + mock_start_next_round, + mock_sync_broadcast_phase_event, + ): + replay_round_config = RoundConfig.objects.create( + session=self.session, + number=2, + category=self.category, + started_from_scoreboard=True, + ) + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.question, + correct_answer=self.question.correct_answer, + ) + replay_session = GameSession.objects.get(pk=self.session.pk) + replay_session.status = GameSession.Status.LIE + replay_session.current_round = 2 + transition = gameplay_services.RoundTransitionResult( + session=replay_session, + round_config=replay_round_config, + round_question=round_question, + should_broadcast=False, + response_payload={"ok": True}, + ) + mock_start_next_round.return_value = transition + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + mock_start_next_round.assert_called_once_with(self.session) + mock_sync_broadcast_phase_event.assert_not_called() + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._finish_game") + def test_finish_game_view_skips_broadcast_on_service_replay( + self, + mock_finish_game, + mock_sync_broadcast_phase_event, + ): + finished_session = GameSession.objects.get(pk=self.session.pk) + finished_session.status = GameSession.Status.FINISHED + transition = gameplay_services.FinishGameResult( + session=finished_session, + should_broadcast=False, + response_payload={"ok": True}, + ) + mock_finish_game.return_value = transition + + response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + mock_finish_game.assert_called_once_with(self.session) + mock_sync_broadcast_phase_event.assert_not_called() + class LobbyFlowTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host", password="secret123") @@ -308,7 +642,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") @@ -1289,6 +1623,25 @@ class RevealRoundFlowTests(TestCase): self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.FINISHED) + @patch("lobby.views.sync_broadcast_phase_event") + def test_finish_game_is_idempotent_after_transition_to_finished(self, mock_sync_broadcast_phase_event): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + + first_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) + second_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(first_response.json(), second_response.json()) + self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.FINISHED) + + self.session.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.FINISHED) + self.assertEqual(mock_sync_broadcast_phase_event.call_count, 2) + self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[0].args[1], "phase.scoreboard") + self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[1].args[1], "phase.game_over") + def test_finish_game_requires_host(self): self.client.login(username="other_reveal", password="secret123") @@ -1348,7 +1701,12 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(self.session.status, GameSession.Status.LIE) self.assertEqual(self.session.current_round, 2) self.assertTrue( - RoundConfig.objects.filter(session=self.session, number=2, category=self.category).exists() + RoundConfig.objects.filter( + session=self.session, + number=2, + category=self.category, + started_from_scoreboard=True, + ).exists() ) self.assertTrue( RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists() @@ -1357,15 +1715,113 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code) self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") + @patch("lobby.views.sync_broadcast_phase_event") + def test_start_next_round_bootstraps_new_round_question_instead_of_reusing_current_round(self, mock_sync_broadcast_phase_event): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + mock_sync_broadcast_phase_event.reset_mock() + + stale_shown_at = timezone.now() - timedelta(minutes=10) + current_round_question = RoundQuestion.objects.get(session=self.session, round_number=1) + current_round_question.shown_at = stale_shown_at + current_round_question.mixed_answers = ["Stale truth", "Stale lie"] + current_round_question.save(update_fields=["shown_at", "mixed_answers"]) + LieAnswer.objects.create(round_question=current_round_question, player=self.player_one, text="Stale lie") + Guess.objects.create( + round_question=current_round_question, + player=self.player_two, + selected_text="Stale truth", + is_correct=True, + ) + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.session.refresh_from_db() + current_round_question.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LIE) + self.assertEqual(self.session.current_round, 2) + payload = response.json() + self.assertEqual(payload["round_question"]["id"], RoundQuestion.objects.get(session=self.session, round_number=2).id) + self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt) + self.assertEqual(current_round_question.round_number, 1) + self.assertEqual(current_round_question.question_id, self.question.id) + self.assertEqual(current_round_question.shown_at, stale_shown_at) + self.assertEqual(current_round_question.mixed_answers, ["Stale truth", "Stale lie"]) + self.assertEqual(current_round_question.lies.count(), 1) + self.assertEqual(current_round_question.guesses.count(), 1) + detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() + self.assertEqual(detail_payload["round_question"]["id"], payload["round_question"]["id"]) + self.assertEqual(detail_payload["round_question"]["prompt"], self.next_question.prompt) + mock_sync_broadcast_phase_event.assert_called_once() + self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") + + @patch("lobby.views.sync_broadcast_phase_event") + def test_start_next_round_is_idempotent_after_transition_to_lie(self, mock_sync_broadcast_phase_event): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + mock_sync_broadcast_phase_event.reset_mock() + + first_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + second_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(first_response.json(), second_response.json()) + self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.LIE) + self.assertEqual(second_response.json()["session"]["current_round"], 2) + + self.session.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LIE) + self.assertEqual(self.session.current_round, 2) + self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) + self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=2).count(), 1) + mock_sync_broadcast_phase_event.assert_called_once() + self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") + + def test_start_next_round_rejects_plain_lie_phase_without_prior_scoreboard_transition(self): + self.client.login(username="host_reveal", password="secret123") + ScoreEvent.objects.filter(session=self.session).delete() + 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.next_question, + correct_answer=self.next_question.correct_answer, + ) + + response = self.client.post( + reverse( + "lobby:start_next_round", + kwargs={"code": self.session.code}, + ), + HTTP_ACCEPT_LANGUAGE="en", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error_code"], "next_round_invalid_phase") + self.session.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LIE) + self.assertEqual(self.session.current_round, 2) + self.assertEqual(RoundConfig.objects.filter(session=self.session, number=1).count(), 1) + self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) + self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=1).count(), 1) + self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=2).count(), 1) + def test_start_next_round_clears_existing_next_round_bootstrap_state(self): self.client.login(username="host_reveal", password="secret123") self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + stale_shown_at = timezone.now() - timedelta(minutes=10) stale_round_question = RoundQuestion.objects.create( session=self.session, round_number=2, question=self.next_question, correct_answer=self.next_question.correct_answer, + shown_at=stale_shown_at, mixed_answers=["Stale truth", "Stale lie"], ) LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Stale lie") @@ -1383,19 +1839,118 @@ class RevealRoundFlowTests(TestCase): stale_round_question.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.LIE) self.assertEqual(self.session.current_round, 2) - self.assertEqual(response.json()["round_question"]["id"], stale_round_question.id) + response_payload = response.json() + self.assertEqual(response_payload["round_question"]["id"], stale_round_question.id) self.assertEqual(stale_round_question.mixed_answers, []) self.assertEqual(stale_round_question.lies.count(), 0) self.assertEqual(stale_round_question.guesses.count(), 0) + self.assertNotEqual(stale_round_question.shown_at, stale_shown_at) + self.assertGreater(stale_round_question.shown_at, stale_shown_at) + self.assertEqual(response_payload["round_question"]["shown_at"], stale_round_question.shown_at.isoformat()) + expected_deadline = stale_round_question.shown_at + timedelta(seconds=self.round_config.lie_seconds) + self.assertEqual(response_payload["round_question"]["lie_deadline_at"], expected_deadline.isoformat()) + self.assertGreater(expected_deadline, timezone.now()) detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE) + + def test_start_next_round_reuses_existing_next_round_config_with_refreshed_canonical_values(self): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + + stale_category = Category.objects.create(name="Sport reveal", slug="sport-reveal", is_active=True) + stale_round_config = RoundConfig.objects.create( + session=self.session, + number=2, + category=stale_category, + lie_seconds=12, + guess_seconds=18, + points_correct=9, + points_bluff=7, + started_from_scoreboard=False, + ) + stale_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.next_question, + correct_answer=self.next_question.correct_answer, + shown_at=timezone.now() - timedelta(minutes=10), + mixed_answers=["Stale truth"], + ) + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.session.refresh_from_db() + stale_round_config.refresh_from_db() + stale_round_question.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LIE) + self.assertEqual(self.session.current_round, 2) + self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) + self.assertEqual(stale_round_config.category_id, self.round_config.category_id) + self.assertEqual(stale_round_config.lie_seconds, self.round_config.lie_seconds) + self.assertEqual(stale_round_config.guess_seconds, self.round_config.guess_seconds) + self.assertEqual(stale_round_config.points_correct, self.round_config.points_correct) + self.assertEqual(stale_round_config.points_bluff, self.round_config.points_bluff) + self.assertTrue(stale_round_config.started_from_scoreboard) + self.assertEqual(response.json()["round_question"]["id"], stale_round_question.id) + self.assertEqual(response.json()["config"]["lie_seconds"], self.round_config.lie_seconds) + expected_deadline = stale_round_question.shown_at + timedelta(seconds=self.round_config.lie_seconds) + self.assertEqual(response.json()["round_question"]["lie_deadline_at"], expected_deadline.isoformat()) + detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() self.assertEqual(detail_payload["session"]["current_round"], 2) self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id) self.assertEqual(detail_payload["round_question"]["answers"], []) self.assertIsNone(detail_payload["reveal"]) self.assertIsNone(detail_payload["scoreboard"]) + def test_start_next_round_repairs_reused_bootstrap_question_with_drifted_category(self): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + + stale_category = Category.objects.create(name="Drift reveal", slug="drift-reveal", is_active=True) + stale_question = Question.objects.create( + category=stale_category, + prompt="Hvem vandt EM i 1992?", + correct_answer="Danmark", + is_active=True, + ) + stale_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=stale_question, + correct_answer=stale_question.correct_answer, + shown_at=timezone.now() - timedelta(minutes=10), + mixed_answers=["Stale truth", "Stale lie"], + ) + LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Tyskland") + Guess.objects.create( + round_question=stale_round_question, + player=self.player_two, + selected_text="Stale truth", + is_correct=True, + ) + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.session.refresh_from_db() + stale_round_question.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LIE) + self.assertEqual(self.session.current_round, 2) + self.assertEqual(stale_round_question.question.category_id, self.round_config.category_id) + self.assertEqual(stale_round_question.question_id, self.next_question.id) + self.assertEqual(stale_round_question.correct_answer, self.next_question.correct_answer) + self.assertEqual(stale_round_question.mixed_answers, []) + self.assertEqual(stale_round_question.lies.count(), 0) + self.assertEqual(stale_round_question.guesses.count(), 0) + payload = response.json() + self.assertEqual(payload["round_question"]["id"], stale_round_question.id) + self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt) + detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() + self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id) + self.assertEqual(detail_payload["round_question"]["prompt"], self.next_question.prompt) + def test_start_next_round_requires_host(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) diff --git a/lobby/views.py b/lobby/views.py index cbd43fc..251888d 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -1,29 +1,39 @@ -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 from django.utils import timezone 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 GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( build_leaderboard as _build_leaderboard, - build_lie_started_payload as _build_lie_started_payload, build_reveal_payload as _build_reveal_payload, + build_scoreboard_phase_event as _build_scoreboard_phase_event, + build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload, ) from fupogfakta.services import ( + finish_game as _finish_game, get_current_round_question as _get_current_round_question, prepare_mixed_answers as _prepare_mixed_answers, + 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 - from .i18n import api_error +_GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = ( + _select_round_question, + _build_scoreboard_phase_event, +) + SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" SESSION_CODE_LENGTH = 6 MAX_CODE_GENERATION_ATTEMPTS = 20 @@ -66,100 +76,16 @@ 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: - if session.status != GameSession.Status.REVEAL: - return session + transition = _promote_reveal_to_scoreboard(session) + if transition.should_broadcast: + sync_broadcast_phase_event( + transition.session.code, + transition.phase_event_name, + transition.phase_event_payload, + ) + return transition.session - current_round_question = _get_current_round_question(session) - if current_round_question is None: - return session - - players_count = Player.objects.filter(session=session).count() - guess_count = Guess.objects.filter(round_question=current_round_question).count() - has_score_events = ScoreEvent.objects.filter( - session=session, - meta__round_question_id=current_round_question.id, - ).exists() - reveal_is_resolved = has_score_events or (players_count > 0 and guess_count >= players_count) - if not reveal_is_resolved: - return session - - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status != GameSession.Status.REVEAL: - return locked_session - locked_session.status = GameSession.Status.SCOREBOARD - locked_session.save(update_fields=["status"]) - - leaderboard = _build_leaderboard(session) - sync_broadcast_phase_event( - session.code, - "phase.scoreboard", - {"leaderboard": list(leaderboard), "current_round": session.current_round}, - ) - session.refresh_from_db(fields=["status"]) - return session - - - -def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict: - status = session.status - in_lobby = status == GameSession.Status.LOBBY - in_lie = status == GameSession.Status.LIE - in_guess = status == GameSession.Status.GUESS - in_scoreboard = status == GameSession.Status.SCOREBOARD - in_finished = status == GameSession.Status.FINISHED - - min_players_reached = players_count >= 3 - max_players_allowed = players_count <= 5 - - return { - "status": status, - "current_phase": status, - "round_number": session.current_round, - "players_count": players_count, - "constraints": { - "min_players_to_start": 3, - "max_players_mvp": 5, - "min_players_reached": min_players_reached, - "max_players_allowed": max_players_allowed, - }, - "readiness": { - "question_ready": has_round_question, - "scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}, - "can_advance_to_next_round": in_scoreboard, - }, - "host": { - "can_start_round": in_lobby and min_players_reached and max_players_allowed, - "can_show_question": False, - "can_mix_answers": False, - "can_calculate_scores": False, - "can_reveal_scoreboard": False, - "can_start_next_round": in_scoreboard, - "can_finish_game": in_scoreboard, - }, - "player": { - "can_join": status in JOINABLE_STATUSES, - "can_submit_lie": in_lie and has_round_question, - "can_submit_guess": in_guess and has_round_question, - "can_view_final_result": in_finished, - }, - } @require_POST @@ -268,21 +194,10 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: session = _maybe_promote_reveal_to_scoreboard(session) current_round_question = _get_current_round_question(session) - - round_question_payload = None - if current_round_question: - round_question_payload = { - "id": current_round_question.id, - "round_number": current_round_question.round_number, - "prompt": current_round_question.question.prompt, - "shown_at": current_round_question.shown_at.isoformat(), - "answers": [{"text": text} for text in (current_round_question.mixed_answers or [])], - } - - phase_view_model = _build_phase_view_model( + gameplay_payload = _build_session_detail_gameplay_payload( session, + current_round_question=current_round_question, players_count=len(players), - has_round_question=bool(current_round_question), ) return JsonResponse( @@ -295,14 +210,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: "players_count": len(players), }, "players": players, - "round_question": round_question_payload, - "reveal": _build_reveal_payload(current_round_question) - if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question - else None, - "scoreboard": _build_leaderboard(session) - if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED} - else None, - "phase_view_model": phase_view_model, + **gameplay_payload, } ) @@ -338,95 +246,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( - { - "session": { - "code": session.code, - "status": session.status, - "current_round": session.current_round, - }, - "round": { - "number": round_config.number, - "category": { - "slug": round_config.category.slug, - "name": round_config.category.name, - }, - }, - "round_question": { - "id": round_question.id, - "prompt": round_question.question.prompt, - "round_number": round_question.round_number, - "shown_at": round_question.shown_at.isoformat(), - "lie_deadline_at": lie_started_payload["lie_deadline_at"], - }, - "config": { - "lie_seconds": round_config.lie_seconds, - }, - }, - status=201, - ) + return JsonResponse(transition.response_payload, status=201) @require_POST @@ -450,60 +286,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) + 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", - { - "round_question_id": round_question.id, - "prompt": round_question.question.prompt, - "shown_at": round_question.shown_at.isoformat(), - "lie_deadline_at": lie_deadline_at.isoformat(), - "lie_seconds": round_config.lie_seconds, - }, + transition.session.code, + transition.phase_event_name, + transition.phase_event_payload, ) - return JsonResponse( - { - "round_question": { - "id": round_question.id, - "prompt": round_question.question.prompt, - "round_number": round_question.round_number, - "shown_at": round_question.shown_at.isoformat(), - "lie_deadline_at": lie_deadline_at.isoformat(), - }, - "config": { - "lie_seconds": round_config.lie_seconds, - }, - }, - status=201, - ) + return JsonResponse(transition.response_payload, status=201) @require_POST @@ -902,22 +696,18 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: if session.host_id != request.user.id: return api_error(request, code="host_only_view_scoreboard", status=403) - session = _maybe_promote_reveal_to_scoreboard(session) + transition = _promote_reveal_to_scoreboard(session) + if transition.should_broadcast: + sync_broadcast_phase_event( + transition.session.code, + transition.phase_event_name, + transition.phase_event_payload, + ) + session = transition.session if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}: return api_error(request, code="scoreboard_invalid_phase", status=400) - leaderboard = _build_leaderboard(session) - - return JsonResponse( - { - "session": { - "code": session.code, - "status": session.status, - "current_round": session.current_round, - }, - "leaderboard": leaderboard, - } - ) + return JsonResponse(transition.response_payload) @require_POST @@ -933,74 +723,19 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: if session.host_id != request.user.id: return api_error(request, code="host_only_start_next_round", status=403) - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status != GameSession.Status.SCOREBOARD: - return api_error(request, code="next_round_invalid_phase", status=400) + try: + transition = _start_next_round(session) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) - 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, + if transition.should_broadcast: + sync_broadcast_phase_event( + transition.session.code, + transition.phase_event_name, + transition.phase_event_payload, ) - locked_session.current_round = next_round_number - try: - round_question = _reset_round_question_bootstrap_state( - _select_round_question(locked_session, next_round_config) - ) - except ValueError as exc: - return api_error(request, code=str(exc), status=400) - - next_round_config.save() - locked_session.status = GameSession.Status.LIE - locked_session.save(update_fields=["current_round", "status"]) - - lie_started_payload = _build_lie_started_payload(locked_session, next_round_config, round_question) - sync_broadcast_phase_event( - locked_session.code, - "phase.lie_started", - lie_started_payload, - ) - - return JsonResponse( - { - "session": { - "code": locked_session.code, - "status": locked_session.status, - "current_round": locked_session.current_round, - }, - "round": { - "number": next_round_config.number, - "category": { - "slug": next_round_config.category.slug, - "name": next_round_config.category.name, - }, - }, - "round_question": { - "id": round_question.id, - "prompt": round_question.question.prompt, - "round_number": round_question.round_number, - "shown_at": round_question.shown_at.isoformat(), - "lie_deadline_at": lie_started_payload["lie_deadline_at"], - }, - "config": { - "lie_seconds": next_round_config.lie_seconds, - }, - } - ) + return JsonResponse(transition.response_payload) @require_POST @login_required @@ -1015,40 +750,19 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: if session.host_id != request.user.id: return api_error(request, code="host_only_finish_game", status=403) - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status != GameSession.Status.SCOREBOARD: - return api_error(request, code="finish_game_invalid_phase", status=400) + try: + transition = _finish_game(session) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) + if transition.should_broadcast: + sync_broadcast_phase_event( + transition.session.code, + transition.phase_event_name, + transition.phase_event_payload, + ) - locked_session.status = GameSession.Status.FINISHED - locked_session.save(update_fields=["status"]) - - leaderboard = list( - Player.objects.filter(session=session) - .order_by("-score", "nickname") - .values("id", "nickname", "score") - ) - - winner = leaderboard[0] if leaderboard else None - - sync_broadcast_phase_event( - session.code, - "phase.game_over", - {"winner": winner, "leaderboard": list(leaderboard)}, - ) - - return JsonResponse( - { - "session": { - "code": session.code, - "status": GameSession.Status.FINISHED, - "current_round": session.current_round, - }, - "winner": winner, - "leaderboard": leaderboard, - } - ) + return JsonResponse(transition.response_payload) @require_POST diff --git a/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json b/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json index 503bb44..7ad6e7f 100644 --- a/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json +++ b/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json @@ -4,7 +4,7 @@ "naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.", "source_of_truth": { "catalog": "shared/i18n/lobby.json", - "catalog_sha256": "e3ed39f2fa25622c01b450bd14fd4da5fc7f96c0d9635bb819f73cae14203beb", + "catalog_sha256": "d9f7227bddd007f2c56f33dfd0015bcffb3b60c52dc756126a02b7e4de638adb", "source_paths": [ "lobby/views.py", "frontend/src/spa/vertical-slice.ts", @@ -24,28 +24,7 @@ }, "parity": { "status": "pass", - "django_backend_error_codes_used_by_mvp": [ - "category_has_no_questions", - "category_not_found", - "category_slug_required", - "host_only_mix_answers", - "host_only_show_question", - "host_only_start_round", - "mix_answers_invalid_phase", - "nickname_invalid", - "nickname_taken", - "no_available_questions", - "not_enough_answers_to_mix", - "question_already_shown", - "round_already_configured", - "round_config_missing", - "round_question_not_found", - "round_start_invalid_phase", - "session_code_required", - "session_not_found", - "session_not_joinable", - "show_question_invalid_phase" - ], + "django_backend_error_codes_used_by_mvp": [], "angular_frontend_error_fallback_keys_used_by_mvp": [ "join_failed", "session_code_required", @@ -158,36 +137,8 @@ "player.submit_lie", "player.title" ], - "backend_codes_mapped_to_frontend_error_keys": { - "category_has_no_questions": "start_round_failed", - "category_not_found": "start_round_failed", - "category_slug_required": "start_round_failed", - "host_only_mix_answers": "start_round_failed", - "host_only_show_question": "start_round_failed", - "host_only_start_round": "start_round_failed", - "mix_answers_invalid_phase": "start_round_failed", - "nickname_invalid": "nickname_invalid", - "nickname_taken": "nickname_taken", - "no_available_questions": "start_round_failed", - "not_enough_answers_to_mix": "start_round_failed", - "question_already_shown": "start_round_failed", - "round_already_configured": "start_round_failed", - "round_config_missing": "start_round_failed", - "round_question_not_found": "start_round_failed", - "round_start_invalid_phase": "start_round_failed", - "session_code_required": "session_code_required", - "session_not_found": "session_not_found", - "session_not_joinable": "join_failed", - "show_question_invalid_phase": "start_round_failed" - }, - "unique_frontend_error_keys_reached_from_django": [ - "join_failed", - "nickname_invalid", - "nickname_taken", - "session_code_required", - "session_not_found", - "start_round_failed" - ], + "backend_codes_mapped_to_frontend_error_keys": {}, + "unique_frontend_error_keys_reached_from_django": [], "blocking_issues": { "missing_backend_codes": [], "missing_backend_translations": [], @@ -201,11 +152,6 @@ "priority": "need-to-have", "item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.", "evidence": "host_only_action" - }, - { - "priority": "nice-to-have", - "item": "Decide whether grouped backend codes should keep collapsing into one Angular fallback key or be split into more specific frontend error copy as UX matures.", - "evidence": "start_round_failed <= category_has_no_questions, category_not_found, category_slug_required, host_only_mix_answers, host_only_show_question, host_only_start_round, mix_answers_invalid_phase, no_available_questions, not_enough_answers_to_mix, question_already_shown, round_already_configured, round_config_missing, round_question_not_found, round_start_invalid_phase, show_question_invalid_phase" } ] }