diff --git a/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md b/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md index 2cc8eeb..9aec970 100644 --- a/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md +++ b/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md @@ -7,8 +7,8 @@ | `POST /lobby/sessions/{code}/rounds/start` | `lobby` | `lie` | Opretter `RoundConfig`, vælger/låser konkret `RoundQuestion`, eksponerer prompt + lie-deadline i samme svar | | Sidste gyldige `submit_lie` for aktivt spørgsmål | `lie` | `guess` | Dedupe/shuffle `correct_answer + lies`, persisterer `mixed_answers`, broadcaster `phase.guess_started` | | Sidste gyldige `submit_guess` for aktivt spørgsmål | `guess` | `reveal` | Beregner score deterministisk, persisterer `ScoreEvent` + opdaterede `Player.score`, returnerer canonical reveal payload | -| Første canonical state-read efter resolved reveal (`session_detail`) | `reveal` | `scoreboard` | Promoverer scoreboard som state, broadcaster `phase.scoreboard`, eksponerer leaderboard + readiness | -| `POST /lobby/sessions/{code}/next` | `scoreboard` | `lobby` | Increment round counter | +| Første canonical state-read efter resolved reveal (`session_detail`, og idempotent `GET /scoreboard` hvis state allerede er resolved) | `reveal` | `scoreboard` | Promoverer scoreboard som state, broadcaster `phase.scoreboard`, eksponerer leaderboard + readiness | +| `POST /lobby/sessions/{code}/next` | `scoreboard` | `lie` | Increment round counter, kopierer seneste `RoundConfig`, vælger/låser næste spørgsmål i samme kategori og broadcaster `phase.lie_started` | | `POST /lobby/sessions/{code}/finish` | `scoreboard` | `finished` | Fryser slutresultat og returnerer final leaderboard | ## Flow-log (happy path) @@ -17,5 +17,6 @@ 2. Server vælger straks spørgsmål og går i `lie`. 3. Spillere sender løgne; sidste submission auto-advancer til `guess`. 4. Spillere sender gæt; sidste submission auto-advancer til `reveal` og scorer runden. -5. Næste `session_detail`-read promoverer resolved reveal til `scoreboard` uden host-knap. +5. Næste canonical state-read promoverer resolved reveal til `scoreboard`; state findes uden separat debug-knap. 6. Host kan nu kun vælge `next round` eller `finish game`. +7. `next round` starter næste runde direkte i `lie` med nyt konkret spørgsmål; ingen mellem-hop tilbage til `lobby`. diff --git a/lobby/tests.py b/lobby/tests.py index 3b0e72d..f59689e 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -19,6 +19,7 @@ from fupogfakta.models import ( Question, RoundConfig, RoundQuestion, + ScoreEvent, ) from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale @@ -947,6 +948,33 @@ class RevealRoundFlowTests(TestCase): self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.REVEAL) self.player_one = Player.objects.create(session=self.session, nickname="Luna", score=9) self.player_two = Player.objects.create(session=self.session, nickname="Mads", score=3) + self.category = Category.objects.create(name="Reveal", slug="reveal", is_active=True) + self.question = Question.objects.create( + category=self.category, + prompt="Hvad er Danmarks hovedstad?", + correct_answer="København", + is_active=True, + ) + self.next_question = Question.objects.create( + category=self.category, + prompt="Hvad er Sveriges hovedstad?", + correct_answer="Stockholm", + is_active=True, + ) + self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category) + self.round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + ) + ScoreEvent.objects.create( + session=self.session, + player=self.player_one, + delta=5, + reason="guess_correct", + meta={"round_question_id": self.round_question.id}, + ) @patch("lobby.views.sync_broadcast_phase_event") def test_host_can_get_reveal_scoreboard(self, mock_sync_broadcast_phase_event): @@ -1062,9 +1090,10 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase") @patch("lobby.views.sync_broadcast_phase_event") - def test_host_can_start_next_round_from_scoreboard(self, _mock_sync_broadcast_phase_event): + def test_host_can_start_next_round_from_scoreboard(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() response = self.client.post( reverse( @@ -1075,12 +1104,24 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(response.status_code, 200) payload = response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.LOBBY) + self.assertEqual(payload["session"]["status"], GameSession.Status.LIE) self.assertEqual(payload["session"]["current_round"], 2) + self.assertEqual(payload["round"]["category"]["slug"], self.category.slug) + self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt) + self.assertEqual(payload["config"]["lie_seconds"], self.round_config.lie_seconds) self.session.refresh_from_db() - self.assertEqual(self.session.status, GameSession.Status.LOBBY) + 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() + ) + self.assertTrue( + RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists() + ) + mock_sync_broadcast_phase_event.assert_called_once() + 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") def test_start_next_round_requires_host(self): self.session.status = GameSession.Status.SCOREBOARD diff --git a/lobby/views.py b/lobby/views.py index 497c235..d7ee99b 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -155,6 +155,20 @@ def _select_round_question(session: GameSession, round_config: RoundConfig) -> R +def _build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict: + lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) + return { + "round_number": session.current_round, + "category": {"slug": round_config.category.slug, "name": round_config.category.name}, + "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, + } + + + def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]: deduped_answers = list(round_question.mixed_answers or []) if deduped_answers: @@ -532,20 +546,12 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: session.status = GameSession.Status.LIE session.save(update_fields=["status"]) - lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) + lie_started_payload = _build_lie_started_payload(session, round_config, round_question) sync_broadcast_phase_event( session.code, "phase.lie_started", - { - "round_number": session.current_round, - "category": {"slug": round_config.category.slug, "name": round_config.category.name}, - "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, - }, + lie_started_payload, ) return JsonResponse( @@ -567,7 +573,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: "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(), + "lie_deadline_at": lie_started_payload["lie_deadline_at"], }, "config": { "lie_seconds": round_config.lie_seconds, @@ -1026,37 +1032,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) - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status not in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD}: - return api_error(request, code="scoreboard_invalid_phase", status=400) - - promoted_to_scoreboard = locked_session.status == GameSession.Status.REVEAL - if promoted_to_scoreboard: - locked_session.status = GameSession.Status.SCOREBOARD - locked_session.save(update_fields=["status"]) - - - leaderboard = list( - Player.objects.filter(session=session) - .order_by("-score", "nickname") - .values("id", "nickname", "score") - ) - - if promoted_to_scoreboard: - sync_broadcast_phase_event( - session.code, - "phase.scoreboard", - {"leaderboard": list(leaderboard), "current_round": locked_session.current_round}, - ) + session = _maybe_promote_reveal_to_scoreboard(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": locked_session.status, - "current_round": locked_session.current_round, + "status": session.status, + "current_round": session.current_round, }, "leaderboard": leaderboard, } @@ -1081,18 +1068,63 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: if locked_session.status != GameSession.Status.SCOREBOARD: return api_error(request, code="next_round_invalid_phase", 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) locked_session.current_round += 1 - locked_session.status = GameSession.Status.LOBBY + next_round_config = RoundConfig.objects.create( + session=locked_session, + number=locked_session.current_round, + 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, + ) + + try: + round_question = _select_round_question(locked_session, next_round_config) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) + + 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": session.code, - "status": GameSession.Status.LOBBY, + "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, + }, } )