From a2c60749f8a7c78e3706b8e4299aae568aea07fd Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 00:44:11 +0000 Subject: [PATCH 1/8] feat(lobby): canonicalize round phase ownership --- ...7-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md | 21 ++ lobby/management/commands/smoke_staging.py | 46 +-- lobby/tests.py | 98 ++++- lobby/views.py | 343 +++++++++++++++--- 4 files changed, 410 insertions(+), 98 deletions(-) create mode 100644 docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md diff --git a/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md b/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md new file mode 100644 index 0000000..2cc8eeb --- /dev/null +++ b/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md @@ -0,0 +1,21 @@ +# Issue #287 — Canonical round-flow backend artifact + +## State-transition matrix + +| Trigger | From | To | Server-owned effect | +|---|---|---|---| +| `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 | +| `POST /lobby/sessions/{code}/finish` | `scoreboard` | `finished` | Fryser slutresultat og returnerer final leaderboard | + +## Flow-log (happy path) + +1. Host starter runde med kategori. +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. +6. Host kan nu kun vælge `next round` eller `finish game`. diff --git a/lobby/management/commands/smoke_staging.py b/lobby/management/commands/smoke_staging.py index f5b1701..c388f9e 100644 --- a/lobby/management/commands/smoke_staging.py +++ b/lobby/management/commands/smoke_staging.py @@ -70,12 +70,9 @@ class Command(BaseCommand): if start_res.status_code != 201: raise CommandError(f"start_round failed: {start_res.status_code}") - show_res = host_client.post(f"/lobby/sessions/{code}/questions/show", content_type="application/json") - if show_res.status_code != 201: - raise CommandError(f"show_question failed: {show_res.status_code}") - - round_question_id = show_res.json()["round_question"]["id"] + round_question_id = start_res.json()["round_question"]["id"] + answers = [] for player in players: nick = player["nickname"] lie_res = Client().post( @@ -91,17 +88,17 @@ class Command(BaseCommand): ) if lie_res.status_code != 201: raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}") + if lie_res.json().get("answers"): + answers = lie_res.json()["answers"] - mix_res = host_client.post( - f"/lobby/sessions/{code}/questions/{round_question_id}/answers/mix", - content_type="application/json", - ) - if mix_res.status_code != 200: - raise CommandError(f"mix_answers failed: {mix_res.status_code}") - - answers = mix_res.json().get("answers", []) if not answers: - raise CommandError("mix_answers returned empty answers") + detail_res = host_client.get(f"/lobby/sessions/{code}") + if detail_res.status_code != 200: + raise CommandError(f"session_detail after lies failed: {detail_res.status_code}") + answers = detail_res.json().get("round_question", {}).get("answers", []) + + if not answers: + raise CommandError("canonical lie->guess transition returned empty answers") for player in players: nick = player["nickname"] @@ -120,16 +117,11 @@ class Command(BaseCommand): if guess_res.status_code != 201: raise CommandError(f"submit_guess failed for {nick}: {guess_res.status_code}") - calc_res = host_client.post( - f"/lobby/sessions/{code}/questions/{round_question_id}/scores/calculate", - content_type="application/json", - ) - if calc_res.status_code != 200: - raise CommandError(f"calculate_scores failed: {calc_res.status_code}") - - board_res = host_client.get(f"/lobby/sessions/{code}/scoreboard") - if board_res.status_code != 200: - raise CommandError(f"reveal_scoreboard failed: {board_res.status_code}") + detail_res = host_client.get(f"/lobby/sessions/{code}") + if detail_res.status_code != 200: + raise CommandError(f"session_detail after guesses failed: {detail_res.status_code}") + if detail_res.json()["session"]["status"] != GameSession.Status.SCOREBOARD: + raise CommandError("canonical guess->reveal->scoreboard transition did not reach scoreboard") finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json") if finish_res.status_code != 200: @@ -148,12 +140,10 @@ class Command(BaseCommand): "create_session", "join_players", "start_round", - "show_question", "submit_lies", - "mix_answers", + "auto_guess_transition", "submit_guesses", - "calculate_scores", - "reveal_scoreboard", + "auto_reveal_to_scoreboard", "finish_game", ], } diff --git a/lobby/tests.py b/lobby/tests.py index c537595..3b0e72d 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -204,11 +204,14 @@ class StartRoundTests(TestCase): self.assertEqual(body["session"]["status"], GameSession.Status.LIE) self.assertEqual(body["round"]["number"], 1) self.assertEqual(body["round"]["category"]["slug"], self.category.slug) + self.assertEqual(body["round_question"]["prompt"], "Hvilket år faldt muren?") + self.assertIn("lie_deadline_at", body["round_question"]) self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.LIE) round_config = RoundConfig.objects.get(session=self.session, number=1) self.assertEqual(round_config.category, self.category) + self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists()) def test_host_start_round_uses_normalized_session_code_from_path(self): self.client.login(username="host", password="secret123") @@ -715,6 +718,76 @@ class GuessSubmissionTests(TestCase): self.assertEqual(response.json()["error"], "Invalid player session token") +class CanonicalRoundFlowTests(TestCase): + def setUp(self): + self.host = User.objects.create_user(username="host_canonical", password="secret123") + self.session = GameSession.objects.create(host=self.host, code="CN2871") + self.category = Category.objects.create(name="Kanon", slug="kanon", is_active=True) + self.question = Question.objects.create( + category=self.category, + prompt="Hvem skrev Hamlet?", + correct_answer="Shakespeare", + is_active=True, + ) + self.players = [ + Player.objects.create(session=self.session, nickname="Luna"), + Player.objects.create(session=self.session, nickname="Mads"), + Player.objects.create(session=self.session, nickname="Nora"), + ] + + def test_canonical_round_flow_auto_advances_from_start_to_scoreboard(self): + self.client.login(username="host_canonical", password="secret123") + + start_response = self.client.post( + reverse("lobby:start_round", kwargs={"code": self.session.code}), + data={"category_slug": self.category.slug}, + content_type="application/json", + ) + self.assertEqual(start_response.status_code, 201) + round_question_id = start_response.json()["round_question"]["id"] + self.assertEqual(start_response.json()["session"]["status"], GameSession.Status.LIE) + + lie_responses = [] + for index, player in enumerate(self.players, start=1): + lie_responses.append( + self.client.post( + reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question_id}), + data={"player_id": player.id, "session_token": player.session_token, "text": f"Løgn {index}"}, + content_type="application/json", + ) + ) + + self.assertTrue(all(response.status_code == 201 for response in lie_responses)) + self.assertEqual(lie_responses[-1].json()["session"]["status"], GameSession.Status.GUESS) + self.assertTrue(lie_responses[-1].json()["phase_transition"]["auto_advanced"]) + self.assertGreaterEqual(len(lie_responses[-1].json()["answers"]), 2) + + guess_targets = ["Shakespeare", "Løgn 1", "Shakespeare"] + guess_responses = [] + for player, selected_text in zip(self.players, guess_targets, strict=True): + guess_responses.append( + self.client.post( + reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question_id}), + data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text}, + content_type="application/json", + ) + ) + + self.assertTrue(all(response.status_code == 201 for response in guess_responses)) + self.assertEqual(guess_responses[-1].json()["session"]["status"], GameSession.Status.REVEAL) + self.assertTrue(guess_responses[-1].json()["phase_transition"]["auto_advanced"]) + self.assertIsNotNone(guess_responses[-1].json()["reveal"]) + + detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})) + self.assertEqual(detail_response.status_code, 200) + payload = detail_response.json() + self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) + self.assertEqual(payload["phase_view_model"]["current_phase"], GameSession.Status.SCOREBOARD) + self.assertTrue(payload["phase_view_model"]["readiness"]["scoreboard_ready"]) + self.assertEqual([entry["nickname"] for entry in payload["scoreboard"]], ["Luna", "Nora", "Mads"]) + self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare") + + class ScoreCalculationTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host_score", password="secret123") @@ -1502,8 +1575,8 @@ class SessionDetailRoundQuestionTests(TestCase): scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() self.assertEqual(reveal_payload["reveal"], scoreboard_payload["reveal"]) - self.assertTrue(reveal_payload["phase_view_model"]["host"]["can_reveal_scoreboard"]) - self.assertFalse(scoreboard_payload["phase_view_model"]["host"]["can_reveal_scoreboard"]) + self.assertTrue(reveal_payload["phase_view_model"]["readiness"]["scoreboard_ready"]) + self.assertTrue(scoreboard_payload["phase_view_model"]["readiness"]["scoreboard_ready"]) self.assertFalse(reveal_payload["phase_view_model"]["host"]["can_start_next_round"]) self.assertTrue(scoreboard_payload["phase_view_model"]["host"]["can_start_next_round"]) @@ -1530,7 +1603,10 @@ class SessionDetailPhaseViewModelTests(TestCase): self.assertTrue(phase["constraints"]["min_players_reached"]) self.assertTrue(phase["constraints"]["max_players_allowed"]) self.assertTrue(phase["host"]["can_start_round"]) + self.assertEqual(phase["current_phase"], GameSession.Status.LOBBY) self.assertFalse(phase["host"]["can_show_question"]) + self.assertFalse(phase["readiness"]["question_ready"]) + self.assertFalse(phase["readiness"]["scoreboard_ready"]) self.assertTrue(phase["player"]["can_join"]) self.assertFalse(phase["player"]["can_submit_lie"]) self.assertFalse(phase["player"]["can_submit_guess"]) @@ -1555,7 +1631,8 @@ class SessionDetailPhaseViewModelTests(TestCase): lie_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() lie_phase = lie_payload["phase_view_model"] self.assertFalse(lie_phase["host"]["can_show_question"]) - self.assertTrue(lie_phase["host"]["can_mix_answers"]) + self.assertFalse(lie_phase["host"]["can_mix_answers"]) + self.assertTrue(lie_phase["readiness"]["question_ready"]) self.assertTrue(lie_phase["player"]["can_submit_lie"]) self.assertFalse(lie_phase["player"]["can_submit_guess"]) @@ -1563,8 +1640,9 @@ class SessionDetailPhaseViewModelTests(TestCase): self.session.save(update_fields=["status"]) guess_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() guess_phase = guess_payload["phase_view_model"] - self.assertTrue(guess_phase["host"]["can_mix_answers"]) - self.assertTrue(guess_phase["host"]["can_calculate_scores"]) + self.assertFalse(guess_phase["host"]["can_mix_answers"]) + self.assertFalse(guess_phase["host"]["can_calculate_scores"]) + self.assertFalse(guess_phase["readiness"]["scoreboard_ready"]) self.assertFalse(guess_phase["player"]["can_submit_lie"]) self.assertTrue(guess_phase["player"]["can_submit_guess"]) @@ -1573,7 +1651,8 @@ class SessionDetailPhaseViewModelTests(TestCase): self.session.save(update_fields=["status"]) reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() reveal_phase = reveal_payload["phase_view_model"] - self.assertTrue(reveal_phase["host"]["can_reveal_scoreboard"]) + self.assertFalse(reveal_phase["host"]["can_reveal_scoreboard"]) + self.assertTrue(reveal_phase["readiness"]["scoreboard_ready"]) self.assertFalse(reveal_phase["host"]["can_start_next_round"]) self.assertFalse(reveal_phase["host"]["can_finish_game"]) self.assertFalse(reveal_phase["player"]["can_view_final_result"]) @@ -1583,6 +1662,7 @@ class SessionDetailPhaseViewModelTests(TestCase): scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() scoreboard_phase = scoreboard_payload["phase_view_model"] self.assertFalse(scoreboard_phase["host"]["can_reveal_scoreboard"]) + self.assertTrue(scoreboard_phase["readiness"]["scoreboard_ready"]) self.assertTrue(scoreboard_phase["host"]["can_start_next_round"]) self.assertTrue(scoreboard_phase["host"]["can_finish_game"]) self.assertFalse(scoreboard_phase["player"]["can_view_final_result"]) @@ -1620,12 +1700,10 @@ class SmokeStagingCommandTests(TestCase): "create_session", "join_players", "start_round", - "show_question", "submit_lies", - "mix_answers", + "auto_guess_transition", "submit_guesses", - "calculate_scores", - "reveal_scoreboard", + "auto_reveal_to_scoreboard", "finish_game", ], ) diff --git a/lobby/views.py b/lobby/views.py index 7899c85..497c235 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -112,6 +112,152 @@ def _build_reveal_payload(round_question: RoundQuestion | None) -> dict | None: +def _build_leaderboard(session: GameSession) -> list[dict]: + return list( + Player.objects.filter(session=session) + .order_by("-score", "nickname") + .values("id", "nickname", "score") + ) + + + +def _get_current_round_question(session: GameSession) -> RoundQuestion | None: + return ( + RoundQuestion.objects.filter(session=session, round_number=session.current_round) + .select_related("question") + .order_by("-id") + .first() + ) + + + +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: + return existing_round_question + + used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True) + available_questions = Question.objects.filter( + category=round_config.category, + is_active=True, + ).exclude(pk__in=used_question_ids) + + if not available_questions.exists(): + raise ValueError("no_available_questions") + + question = random.choice(list(available_questions)) + return RoundQuestion.objects.create( + session=session, + round_number=session.current_round, + question=question, + correct_answer=question.correct_answer, + ) + + + +def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]: + deduped_answers = list(round_question.mixed_answers or []) + if deduped_answers: + return deduped_answers + + lie_texts = list(round_question.lies.values_list("text", flat=True)) + seen = set() + for text in [round_question.correct_answer, *lie_texts]: + normalized = text.strip().casefold() + if not normalized or normalized in seen: + continue + seen.add(normalized) + deduped_answers.append(text.strip()) + + if len(deduped_answers) < 2: + raise ValueError("not_enough_answers_to_mix") + + random.shuffle(deduped_answers) + round_question.mixed_answers = deduped_answers + round_question.save(update_fields=["mixed_answers"]) + return deduped_answers + + + +def _resolve_scores(session: GameSession, round_question: RoundQuestion, round_config: RoundConfig) -> tuple[list[ScoreEvent], list[dict]]: + guesses = list(round_question.guesses.select_related("player")) + if not guesses: + raise ValueError("no_guesses_submitted") + + bluff_counts: dict[int, int] = {} + for guess in guesses: + if guess.fooled_player_id: + bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1 + + score_events = [] + + for guess in guesses: + if guess.is_correct: + guess.player.score += round_config.points_correct + guess.player.save(update_fields=["score"]) + score_events.append( + ScoreEvent( + session=session, + player=guess.player, + delta=round_config.points_correct, + reason="guess_correct", + meta={"round_question_id": round_question.id, "guess_id": guess.id}, + ) + ) + + for player_id, fooled_count in bluff_counts.items(): + delta = fooled_count * round_config.points_bluff + player = Player.objects.get(pk=player_id, session=session) + player.score += delta + player.save(update_fields=["score"]) + score_events.append( + ScoreEvent( + session=session, + player=player, + delta=delta, + reason="bluff_success", + meta={"round_question_id": round_question.id, "fooled_count": fooled_count}, + ) + ) + + ScoreEvent.objects.bulk_create(score_events) + return score_events, _build_leaderboard(session) + + + +def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession: + if session.status != GameSession.Status.REVEAL: + return session + + current_round_question = _get_current_round_question(session) + if current_round_question is None: + return session + + has_round_scores = ScoreEvent.objects.filter( + session=session, + meta__round_question_id=current_round_question.id, + ).exists() + if not has_round_scores: + 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 @@ -126,6 +272,7 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou return { "status": status, + "current_phase": status, "round_number": session.current_round, "players_count": players_count, "constraints": { @@ -134,12 +281,17 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou "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": in_lie and not has_round_question, - "can_mix_answers": in_lie or in_guess, - "can_calculate_scores": in_guess, - "can_reveal_scoreboard": in_reveal, + "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, }, @@ -256,12 +408,8 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: ) ) - current_round_question = ( - RoundQuestion.objects.filter(session=session, round_number=session.current_round) - .select_related("question") - .order_by("-id") - .first() - ) + session = _maybe_promote_reveal_to_scoreboard(session) + current_round_question = _get_current_round_question(session) round_question_payload = None if current_round_question: @@ -293,6 +441,9 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: "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, } ) @@ -373,15 +524,26 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: status=409, ) + try: + round_question = _select_round_question(session, round_config) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) + session.status = GameSession.Status.LIE session.save(update_fields=["status"]) + lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) + 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, }, ) @@ -400,6 +562,16 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: "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_deadline_at.isoformat(), + }, + "config": { + "lie_seconds": round_config.lie_seconds, + }, }, status=201, ) @@ -442,33 +614,14 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: status=400, ) - if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists(): - return api_error( - request, - code="question_already_shown", - status=409, - ) - - used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True) - available_questions = Question.objects.filter( - category=round_config.category, - is_active=True, - ).exclude(pk__in=used_question_ids) - - if not available_questions.exists(): - return api_error( - request, - code="no_available_questions", - status=400, - ) - - question = random.choice(list(available_questions)) - round_question = RoundQuestion.objects.create( - session=session, - round_number=session.current_round, - question=question, - correct_answer=question.correct_answer, - ) + 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) @@ -477,7 +630,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: "phase.question_shown", { "round_question_id": round_question.id, - "prompt": question.prompt, + "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, @@ -488,7 +641,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: { "round_question": { "id": round_question.id, - "prompt": question.prompt, + "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(), @@ -558,6 +711,31 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR except IntegrityError: return api_error(request, code="lie_already_submitted", status=409) + players_count = Player.objects.filter(session=session).count() + lie_count = LieAnswer.objects.filter(round_question=round_question).count() + session_status = session.status + mixed_answers_payload = None + + if players_count > 0 and lie_count >= players_count: + try: + mixed_answers = _prepare_mixed_answers(round_question) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) + + session.status = GameSession.Status.GUESS + session.save(update_fields=["status"]) + session_status = session.status + mixed_answers_payload = [{"text": text} for text in mixed_answers] + sync_broadcast_phase_event( + session.code, + "phase.guess_started", + { + "round_question_id": round_question.id, + "answers": mixed_answers_payload, + "guess_seconds": round_config.guess_seconds, + }, + ) + return JsonResponse( { "lie": { @@ -570,6 +748,18 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR "window": { "lie_deadline_at": lie_deadline_at.isoformat(), }, + "session": { + "code": session.code, + "status": session_status, + "current_round": session.current_round, + }, + "phase_transition": { + "current_phase": session_status, + "lies_submitted": lie_count, + "players_expected": players_count, + "auto_advanced": session_status == GameSession.Status.GUESS, + }, + "answers": mixed_answers_payload, }, status=201, ) @@ -626,27 +816,10 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk) - deduped_answers = list(locked_round_question.mixed_answers or []) - if not deduped_answers: - lie_texts = list(locked_round_question.lies.values_list("text", flat=True)) - seen = set() - for text in [locked_round_question.correct_answer, *lie_texts]: - normalized = text.strip().casefold() - if not normalized or normalized in seen: - continue - seen.add(normalized) - deduped_answers.append(text.strip()) - - if len(deduped_answers) < 2: - return api_error( - request, - code="not_enough_answers_to_mix", - status=400, - ) - - random.shuffle(deduped_answers) - locked_round_question.mixed_answers = deduped_answers - locked_round_question.save(update_fields=["mixed_answers"]) + try: + deduped_answers = _prepare_mixed_answers(locked_round_question) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) if locked_session.status == GameSession.Status.LIE: locked_session.status = GameSession.Status.GUESS @@ -769,6 +942,43 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso except IntegrityError: return api_error(request, code="guess_already_submitted", status=409) + players_count = Player.objects.filter(session=session).count() + guess_count = Guess.objects.filter(round_question=round_question).count() + session_status = session.status + reveal_payload = None + leaderboard = None + + if players_count > 0 and guess_count >= players_count: + already_calculated = ScoreEvent.objects.filter( + session=session, + meta__round_question_id=round_question.id, + ).exists() + if not already_calculated: + score_events, leaderboard = _resolve_scores(session, round_question, round_config) + else: + score_events = list( + ScoreEvent.objects.filter(session=session, meta__round_question_id=round_question.id).select_related("player") + ) + leaderboard = _build_leaderboard(session) + + session.status = GameSession.Status.REVEAL + session.save(update_fields=["status"]) + session_status = session.status + reveal_payload = _build_reveal_payload(round_question) + score_deltas = [ + {"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason} + for ev in score_events + ] + sync_broadcast_phase_event( + session.code, + "phase.scores_calculated", + { + "round_question_id": round_question.id, + "score_deltas": score_deltas, + "leaderboard": list(leaderboard), + }, + ) + return JsonResponse( { "guess": { @@ -783,6 +993,19 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso "window": { "guess_deadline_at": guess_deadline_at.isoformat(), }, + "session": { + "code": session.code, + "status": session_status, + "current_round": session.current_round, + }, + "phase_transition": { + "current_phase": session_status, + "guesses_submitted": guess_count, + "players_expected": players_count, + "auto_advanced": session_status == GameSession.Status.REVEAL, + }, + "reveal": reveal_payload, + "leaderboard": leaderboard, }, status=201, ) From ab08dc2b6d0541252de9cadf7116370ea13dd0ba Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 01:00:07 +0000 Subject: [PATCH 2/8] feat(lobby): align canonical round flow for issue 287 --- ...7-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md | 7 +- lobby/tests.py | 47 +++++++- lobby/views.py | 112 +++++++++++------- 3 files changed, 120 insertions(+), 46 deletions(-) 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, + }, } ) From 5bb035deec4321dfb5dda2b3b7091ea10a61e84f Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 02:07:17 +0000 Subject: [PATCH 3/8] fix(lobby): tighten canonical host round flow for issue 287 --- ...7-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md | 2 +- .../src/app/api-contract-smoke.spec.ts | 2 +- .../host/host-shell.component.spec.ts | 117 ++++-------------- .../app/features/host/host-shell.component.ts | 73 +---------- frontend/tests/angular-api-client.test.ts | 2 +- lobby/templates/lobby/host_screen.html | 32 ++--- lobby/tests.py | 11 +- 7 files changed, 49 insertions(+), 190 deletions(-) diff --git a/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md b/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md index 9aec970..2aec3b7 100644 --- a/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md +++ b/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md @@ -8,7 +8,7 @@ | 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`, 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}/rounds/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) diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts index 63b72b5..c496c94 100644 --- a/frontend/angular/src/app/api-contract-smoke.spec.ts +++ b/frontend/angular/src/app/api-contract-smoke.spec.ts @@ -140,7 +140,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { if (url === '/lobby/sessions/ABCD12/rounds/next') { expect(body).toEqual({}); - return { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } } as T; + return { session: { code: 'ABCD12', status: 'lie', current_round: 2 } } as T; } if (url === '/lobby/sessions/ABCD12/finish') { diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 82fb35f..8fe8674 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -85,10 +85,10 @@ function sessionDetailPayload( }, host: { can_start_round: status === 'lobby', - can_show_question: status === 'lie', - can_mix_answers: status === 'lie', - can_calculate_scores: status === 'guess', - can_reveal_scoreboard: status === 'reveal', + can_show_question: false, + can_mix_answers: false, + can_calculate_scores: false, + can_reveal_scoreboard: false, can_start_next_round: status === 'scoreboard', can_finish_game: status === 'scoreboard', }, @@ -179,80 +179,16 @@ describe('HostShellComponent gameplay wiring', () => { }); }); - it('captures scoreboard error for retry path', async () => { - const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' })); - - vi.stubGlobal('fetch', fetchMock); - - const component = new HostShellComponent(); - component.sessionCode = 'ABCD12'; - - await component.loadScoreboard(); - - expect(fetchMock).toHaveBeenCalledWith('/lobby/sessions/ABCD12/scoreboard', expect.objectContaining({ method: 'GET' })); - expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable'); - expect(component.loading).toBe(false); - }); - - it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => { + it('runs next-round transition into canonical lie phase and clears prior final leaderboard state', async () => { const fetchMock: FetchMock = vi .fn() - .mockResolvedValueOnce( - jsonResponse(200, { - round_question: { - id: 77, - round_number: 1, - prompt: 'Q?', - shown_at: '2026-01-01T00:00:00Z', - lie_deadline_at: '2026-01-01T00:00:45Z', - }, - config: { lie_seconds: 45 }, - }) - ) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 77 }))) - .mockResolvedValueOnce( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'guess', current_round: 1 }, - round_question: { id: 77, round_number: 1 }, - answers: [{ text: 'A' }], - }) - ) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))) - .mockResolvedValueOnce( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, - round_question: { id: 77, round_number: 1 }, - events_created: 2, - leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }], - }) - ) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }))); + .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } })) + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }))); vi.stubGlobal('fetch', fetchMock); const component = new HostShellComponent(); component.sessionCode = ' abcd12 '; - component.roundQuestionId = ' 77 '; - - await component.showQuestion(); - await component.mixAnswers(); - await component.calculateScores(); - - expect(component.error).toBe(''); - expect(component.loading).toBe(false); - }); - - it('runs next-round transition without reload and clears scoreboard payload', async () => { - const fetchMock: FetchMock = vi - .fn() - .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } })) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null }))); - - vi.stubGlobal('fetch', fetchMock); - - const component = new HostShellComponent(); - component.sessionCode = ' abcd12 '; - component.scoreboardPayload = '{"leaderboard":[]}'; component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ; component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }]; @@ -264,8 +200,8 @@ describe('HostShellComponent gameplay wiring', () => { expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) ); expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' })); - expect(component.session?.session.status).toBe('lobby'); - expect(component.scoreboardPayload).toBe(''); + expect(component.session?.session.status).toBe('lie'); + expect(component.roundQuestionId).toBe('99'); expect(component.finalLeaderboardPayload).toBe(''); expect(component.finalLeaderboard).toEqual([]); expect(component.nextRoundError).toBe(''); @@ -341,25 +277,24 @@ describe('HostShellComponent gameplay wiring', () => { expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12'); expect(component.canStartRound).toBe(false); - expect(component.canShowQuestion).toBe(false); - expect(component.canMixAnswers).toBe(false); - expect(component.canCalculateScores).toBe(true); - }); - - it('uses phase_view_model to keep host action surface phase-specific', async () => { - const component = new HostShellComponent(); - - expect(component.canStartRound).toBe(true); - expect(component.canShowQuestion).toBe(false); - - component.session = sessionDetailPayload('lie') as any; - expect(component.canStartRound).toBe(false); - expect(component.canShowQuestion).toBe(true); - expect(component.canMixAnswers).toBe(true); - - component.session = sessionDetailPayload('reveal') as any; - expect(component.canRevealScoreboard).toBe(true); expect(component.canStartNextRound).toBe(false); expect(component.canFinishGame).toBe(false); }); + + it('uses phase_view_model to keep host action surface bound to round boundaries only', async () => { + const component = new HostShellComponent(); + + expect(component.canStartRound).toBe(true); + expect(component.canStartNextRound).toBe(false); + expect(component.canFinishGame).toBe(false); + + component.session = sessionDetailPayload('lie') as any; + expect(component.canStartRound).toBe(false); + expect(component.canStartNextRound).toBe(false); + expect(component.canFinishGame).toBe(false); + + component.session = sessionDetailPayload('scoreboard') as any; + expect(component.canStartNextRound).toBe(true); + expect(component.canFinishGame).toBe(true); + }); }); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 89c6ac9..83d9165 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -3,13 +3,13 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; -import type { FinishGameResponse, ScoreboardResponse, SessionDetailResponse } from '../../../../../src/api/types'; +import type { FinishGameResponse, SessionDetailResponse } from '../../../../../src/api/types'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; type SessionDetail = SessionDetailResponse; -type LeaderboardEntry = ScoreboardResponse['leaderboard'][number]; +type LeaderboardEntry = FinishGameResponse['leaderboard'][number]; type LeaderboardResponse = FinishGameResponse; @Component({ @@ -24,17 +24,12 @@ type LeaderboardResponse = FinishGameResponse; - - - -

{{ copy('host.audio_locale_hint') }}: {{ locale }}

{{ error }}

-

{{ scoreboardError }}

{{ nextRoundError }}

{{ finishError }}

@@ -67,7 +62,6 @@ type LeaderboardResponse = FinishGameResponse; -
{{ scoreboardPayload }}

{{ copy('host.final_leaderboard') }}

{{ copy('host.winner') }}: {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }})

@@ -88,10 +82,8 @@ export class HostShellComponent implements OnInit, OnDestroy { roundQuestionId = ''; loading = false; error = ''; - scoreboardError = ''; nextRoundError = ''; finishError = ''; - scoreboardPayload = ''; finalLeaderboardPayload = ''; finalLeaderboard: LeaderboardEntry[] = []; finalWinner: LeaderboardEntry | null = null; @@ -133,22 +125,6 @@ export class HostShellComponent implements OnInit, OnDestroy { return Boolean(this.session?.phase_view_model?.host?.can_start_round ?? !this.session); } - get canShowQuestion(): boolean { - return Boolean(this.session?.phase_view_model?.host?.can_show_question); - } - - get canMixAnswers(): boolean { - return Boolean(this.session?.phase_view_model?.host?.can_mix_answers); - } - - get canCalculateScores(): boolean { - return Boolean(this.session?.phase_view_model?.host?.can_calculate_scores); - } - - get canRevealScoreboard(): boolean { - return Boolean(this.session?.phase_view_model?.host?.can_reveal_scoreboard); - } - get canStartNextRound(): boolean { return Boolean(this.session?.phase_view_model?.host?.can_start_next_round); } @@ -193,7 +169,6 @@ export class HostShellComponent implements OnInit, OnDestroy { async refreshSession(): Promise { this.loading = true; this.error = ''; - this.scoreboardError = ''; this.nextRoundError = ''; this.finishError = ''; try { @@ -226,54 +201,11 @@ export class HostShellComponent implements OnInit, OnDestroy { this.sessionCode = this.session.session.code; this.persistSessionCode(this.sessionCode); this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; - this.scoreboardPayload = ''; this.resetFinalLeaderboard(); this.syncRouteFromSession(); }); } - async showQuestion(): Promise { - await this.runAction(async () => { - const code = this.normalizeCode(this.sessionCode); - await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {}); - await this.refreshSession(); - }); - } - - async mixAnswers(): Promise { - await this.runAction(async () => { - const code = this.normalizeCode(this.sessionCode); - const roundQuestionId = this.roundQuestionId.trim(); - await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/answers/mix`, 'POST', {}); - await this.refreshSession(); - }); - } - - async calculateScores(): Promise { - await this.runAction(async () => { - const code = this.normalizeCode(this.sessionCode); - const roundQuestionId = this.roundQuestionId.trim(); - await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/scores/calculate`, 'POST', {}); - await this.refreshSession(); - }); - } - - async loadScoreboard(): Promise { - this.loading = true; - this.scoreboardError = ''; - this.error = ''; - try { - const code = this.normalizeCode(this.sessionCode); - const payload = await this.request(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET'); - this.scoreboardPayload = JSON.stringify(payload, null, 2); - await this.refreshSession(); - } catch (error) { - this.scoreboardError = `${this.copy('host.scoreboard_failed')}: ${(error as Error).message}`; - } finally { - this.loading = false; - } - } - async startNextRound(): Promise { this.loading = true; this.nextRoundError = ''; @@ -284,7 +216,6 @@ export class HostShellComponent implements OnInit, OnDestroy { throw new Error(this.copy('host.session_code_required')); } await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {}); - this.scoreboardPayload = ''; this.resetFinalLeaderboard(); await this.refreshSession(); } catch (error) { diff --git a/frontend/tests/angular-api-client.test.ts b/frontend/tests/angular-api-client.test.ts index 275f463..328d4f8 100644 --- a/frontend/tests/angular-api-client.test.ts +++ b/frontend/tests/angular-api-client.test.ts @@ -634,7 +634,7 @@ describe('createAngularApiClient', () => { } if (url === '/lobby/sessions/ABCD12/rounds/next') { expect(body).toEqual({}); - return { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } } as T; + return { session: { code: 'ABCD12', status: 'lie', current_round: 2 } } as T; } if (url === '/lobby/sessions/ABCD12/finish') { expect(body).toEqual({}); diff --git a/lobby/templates/lobby/host_screen.html b/lobby/templates/lobby/host_screen.html index 3c66382..6ffdee5 100644 --- a/lobby/templates/lobby/host_screen.html +++ b/lobby/templates/lobby/host_screen.html @@ -24,14 +24,10 @@

Kategori er kun redigérbar i lobby-fasen.

Fase: ukendt (opdatér session-status).

- - -

Round question-id kan kun redigeres i lie/guess/reveal-faser.

- - - - - +

Aktiv round question: afventer session-status.

+

Round question-id styres server-side i canonical flow og er kun read-only kontekst for host.

+ +

Angiv sessionkode for at aktivere host-actions.

Ingen fejl.

@@ -74,9 +70,10 @@ var hostShellFatalError=false; var hostShellRecoverInFlight=false; var hostCriticalHydrated=false; function setHostCriticalLoading(isLoading){var skeleton=document.getElementById("hostCriticalSkeleton");var view=document.getElementById("hostCriticalView");if(!skeleton||!view){return;}skeleton.style.display=isLoading?"block":"none";view.style.display=isLoading?"none":"block";} -function hydrateHostCriticalView(data){var session=(data&&data.session)||{};var phaseEl=document.getElementById("hostCriticalPhase");var playersEl=document.getElementById("hostCriticalPlayers");var roundEl=document.getElementById("hostCriticalRound");if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||session.status||"");} +function hydrateHostCriticalView(data){var session=(data&&data.session)||{};var phaseEl=document.getElementById("hostCriticalPhase");var playersEl=document.getElementById("hostCriticalPlayers");var roundEl=document.getElementById("hostCriticalRound");var roundStatus=document.getElementById("roundQuestionStatus");var roundQuestionId=(data&&data.round_question&&data.round_question.id)?String(data.round_question.id):"";if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||session.status||"");} if(playersEl){playersEl.textContent="Spillere: "+(typeof session.players_count==="number"?session.players_count:"ukendt");} -if(roundEl){roundEl.textContent="Aktiv round question: "+(rq()||"ikke valgt");} +if(roundEl){roundEl.textContent="Aktiv round question: "+(roundQuestionId||"ikke valgt");} +if(roundStatus){roundStatus.textContent="Aktiv round question: "+(roundQuestionId||"afventer session-status.");} hostCriticalHydrated=true; setHostCriticalLoading(false); } @@ -86,9 +83,8 @@ function setHostShellFatalError(detail){hostShellFatalError=true;var out=documen function clearHostShellFatalError(){hostShellFatalError=false;hostShellRecoverInFlight=false;updateHostShellErrorBoundary();} function recoverHostShell(mode){if(hostShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}hostShellRecoverInFlight=true;updateHostShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearHostShellFatalError();return result;}).catch(function(err){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();throw err;});} function code(){return document.getElementById("code").value.trim().toUpperCase();} -function rq(){return document.getElementById("roundQuestionId").value.trim();} -function saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),round_question_id:rq(),session_status:currentSessionStatus||"",auto_refresh:autoRefreshEnabled}));}catch(_e){}} -function restoreHostContext(){try{var raw=localStorage.getItem("wppHostContext");if(!raw){return false;}var ctx=JSON.parse(raw);if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.round_question_id){document.getElementById("roundQuestionId").value=ctx.round_question_id;}if(ctx.session_status){currentSessionStatus=ctx.session_status;}autoRefreshEnabled=!!ctx.auto_refresh;updateAutoRefreshUi();return !!ctx.code;}catch(_e){return false;}} +function saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),session_status:currentSessionStatus||"",auto_refresh:autoRefreshEnabled}));}catch(_e){}} +function restoreHostContext(){try{var raw=localStorage.getItem("wppHostContext");if(!raw){return false;}var ctx=JSON.parse(raw);if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.session_status){currentSessionStatus=ctx.session_status;}autoRefreshEnabled=!!ctx.auto_refresh;updateAutoRefreshUi();return !!ctx.code;}catch(_e){return false;}} function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Lie";}if(status==="guess"){return"Guess";}if(status==="reveal"){return"Reveal";}if(status==="scoreboard"){return"Scoreboard";}if(status==="finished"){return"Finished";}return"Unknown";} function hostShellRouteFromPath(){var marker="/lobby/ui/host";var path=(window.location.pathname||"").toLowerCase();var idx=path.indexOf(marker);if(idx===-1){return"";}var remainder=path.slice(idx+marker.length).replace(/^\/+|\/+$/g,"");if(!remainder){return"";}var route=remainder.split("/")[0];return HOST_SHELL_ROUTES[route]?route:"";} function expectedHostShellRoute(){return HOST_SHELL_ROUTES[currentSessionStatus]||"";} @@ -108,20 +104,16 @@ function updateCreateSessionState(){var btn=document.getElementById("createSessi function updatePhaseStatus(){var el=document.getElementById("phaseStatus");syncHostShellRoute();if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";} function syncStartRoundGuard(data){var btn=document.getElementById("startRoundBtn");var hint=document.getElementById("startRoundHint");var status=document.getElementById("playerCountStatus");if(!btn||!hint||!status){return;}var count=(data&&data.session&&typeof data.session.players_count==="number")?data.session.players_count:null;var phase=currentSessionStatus||"";if(phase&&phase!=="lobby"){btn.disabled=true;status.textContent=count===null?"Spillere i session: ukendt":"Spillere i session: "+count;hint.textContent="Start runde er kun tilladt i lobby-fasen.";return;}if(count===null){btn.disabled=true;status.textContent="Spillere i session: ukendt";hint.textContent="Opdatér session-status for at validere 3-5 spillere.";return;}status.textContent="Spillere i session: "+count;if(count<3){btn.disabled=true;hint.textContent="Mangler spillere: kræver mindst 3 for at starte runde.";return;}if(count>5){btn.disabled=true;hint.textContent="For mange spillere: maks 5 i MVP før runde-start.";return;}btn.disabled=false;hint.textContent="Klar: spillerantal er indenfor 3-5 til runde-start.";} -function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}if(mixAnswersBtn){mixAnswersBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||(phase!=="lie"&&phase!=="guess");}if(calcScoresBtn){calcScoresBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||phase!=="guess";}var canEditRoundQuestion=!!hasCode&&(phase==="lie"||phase==="guess"||phase==="reveal");if(roundQuestionInput){roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion;}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at redigere round question-id.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-id.";}else if(canEditRoundQuestion){roundQuestionGuardHint.textContent="Round question-id kan redigeres i fase: "+phaseLabel(phase)+".";}else{roundQuestionGuardHint.textContent="Round question-id er låst i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if((phase==="lie"||phase==="guess")&&!hasRound){hint.textContent="Round question id mangler: mix/beregn score er låst.";return;}if(hostShellRouteHint){hint.textContent=hostShellRouteHint;return;}hint.textContent="Host-actions er klar for fase: "+phaseLabel(phase)+".";} -async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session){hydrateHostCriticalView(d);}updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}if(hostShellFatalError){clearHostShellFatalError();}saveHostContext();return d;} +function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var phase=currentSessionStatus||"";var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at se aktiv round question.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-kontekst.";}else{roundQuestionGuardHint.textContent="Round question-id styres server-side i canonical flow og er read-only i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if(phase==="scoreboard"){hint.textContent="Host-actions er klar: vælg næste runde eller afslut spillet.";return;}if(hostShellRouteHint){hint.textContent=hostShellRouteHint;return;}hint.textContent="Mid-round faseskift er server-styrede i canonical flow. Host monitorerer kun fremdrift i fase: "+phaseLabel(phase)+".";} +async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.session){hydrateHostCriticalView(d);}updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}if(hostShellFatalError){clearHostShellFatalError();}saveHostContext();return d;} function withHostActionLock(fn){if(hostActionInFlight){return Promise.resolve({error:"host_action_in_flight"});}hostActionInFlight=true;updateHostActionState();return Promise.resolve().then(fn).finally(function(){hostActionInFlight=false;updateHostActionState();});} function createSession(){return withHostActionLock(function(){return api("/lobby/sessions/create","POST",{});});} function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});} function startRound(){if(document.getElementById("startRoundBtn").disabled){return Promise.resolve({error:"not_enough_players_client_guard"});}return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/rounds/start","POST",{category_slug:document.getElementById("category").value});});} -function showQuestion(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/show","POST",{});});} -function mixAnswers(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/answers/mix","POST",{});});} -function calcScores(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/scores/calculate","POST",{});});} -function showScoreboard(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/scoreboard","GET",null);});} function nextRound(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/rounds/next","POST",{});});} function finishGame(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/finish","POST",{});});} -["code","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});field.addEventListener("change",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});}); +["code"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});field.addEventListener("change",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});}); window.addEventListener("error",function(event){setHostShellFatalError((event&&event.message)||"Ukendt runtime-fejl");}); window.addEventListener("unhandledrejection",function(event){var reason=event&&event.reason;var detail=(reason&&reason.message)||String(reason||"Unhandled promise rejection");setHostShellFatalError(detail);}); diff --git a/lobby/tests.py b/lobby/tests.py index f59689e..bb6f6d5 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1218,8 +1218,7 @@ class UiScreenTests(TestCase): self.assertContains(response, "id=\"createSessionHint\"") self.assertContains(response, "saveHostContext") self.assertContains(response, "restoreHostContext") - self.assertContains(response, "id=\"showQuestionBtn\"") - self.assertContains(response, "id=\"mixAnswersBtn\"") + self.assertContains(response, "id=\"roundQuestionStatus\"") self.assertContains(response, "id=\"hostActionHint\"") self.assertContains(response, "id=\"categoryGuardHint\"") @@ -1231,8 +1230,8 @@ class UiScreenTests(TestCase): self.assertContains(response, "Kategori er kun redigérbar i lobby-fasen.") self.assertContains(response, "Kræver 3-5 spillere i lobbyen.") self.assertContains(response, "For mange spillere: maks 5 i MVP før runde-start.") - self.assertContains(response, "Round question-id kan kun redigeres i lie/guess/reveal-faser.") - self.assertContains(response, "roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion") + self.assertContains(response, "Round question-id styres server-side i canonical flow og er kun read-only kontekst for host.") + self.assertContains(response, "Round question-id styres server-side i canonical flow og er read-only i fase:") self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==\"lobby\"") self.assertContains(response, "hostActionInFlight") self.assertContains(response, "withHostActionLock") @@ -1261,7 +1260,9 @@ class UiScreenTests(TestCase): self.assertContains(response, "markSessionRefresh") self.assertContains(response, "updateLastRefreshStatus") self.assertContains(response, "isSessionDetailRead") - self.assertContains(response, "showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==") + self.assertContains(response, "Mid-round faseskift er server-styrede i canonical flow. Host monitorerer kun fremdrift i fase:") + self.assertContains(response, "Host-actions er klar: vælg næste runde eller afslut spillet.") + self.assertContains(response, 'if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}') self.assertContains(response, "Host-actions er låst mens session-opdatering kører.") self.assertContains(response, "Round question-id er låst mens session-opdatering kører.") self.assertContains(response, "Kategori er låst mens session-opdatering kører.") From a6e09e2bea6b39da32b2172126efe1b7ba91522c Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 02:20:57 +0000 Subject: [PATCH 4/8] fix(lobby): remove dead reveal state flag --- lobby/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lobby/views.py b/lobby/views.py index d7ee99b..7ab41ee 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -277,7 +277,6 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou in_lobby = status == GameSession.Status.LOBBY in_lie = status == GameSession.Status.LIE in_guess = status == GameSession.Status.GUESS - in_reveal = status == GameSession.Status.REVEAL in_scoreboard = status == GameSession.Status.SCOREBOARD in_finished = status == GameSession.Status.FINISHED From 3706bc3b1c9e29bc5af2ce26d4a24d375bd638c4 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 02:42:19 +0000 Subject: [PATCH 5/8] fix(lobby): guard auto score calculation --- lobby/tests.py | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++ lobby/views.py | 78 ++++++++++++++++++++++++++++++----------------- 2 files changed, 133 insertions(+), 27 deletions(-) diff --git a/lobby/tests.py b/lobby/tests.py index bb6f6d5..081fa1c 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -788,6 +788,88 @@ class CanonicalRoundFlowTests(TestCase): self.assertEqual([entry["nickname"] for entry in payload["scoreboard"]], ["Luna", "Nora", "Mads"]) self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare") + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._resolve_scores") + @patch("lobby.views.GameSession.objects.get") + def test_submit_guess_skips_rescore_when_locked_session_is_already_revealing( + self, + mock_session_get, + mock_resolve_scores, + mock_sync_broadcast, + ): + round_config = RoundConfig.objects.create( + session=self.session, + number=1, + category=self.category, + points_correct=5, + points_bluff=2, + ) + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer="Shakespeare", + ) + LieAnswer.objects.create(round_question=round_question, player=self.players[0], text="Marlowe") + Guess.objects.create( + round_question=round_question, + player=self.players[0], + selected_text="Shakespeare", + is_correct=True, + ) + Guess.objects.create( + round_question=round_question, + player=self.players[1], + selected_text="Marlowe", + is_correct=False, + fooled_player=self.players[0], + ) + self.players[0].score = round_config.points_correct + round_config.points_bluff + self.players[0].save(update_fields=["score"]) + ScoreEvent.objects.create( + session=self.session, + player=self.players[0], + delta=round_config.points_correct, + reason="guess_correct", + meta={"round_question_id": round_question.id, "guess_id": 1}, + ) + ScoreEvent.objects.create( + session=self.session, + player=self.players[0], + delta=round_config.points_bluff, + reason="bluff_success", + meta={"round_question_id": round_question.id, "fooled_count": 1}, + ) + self.session.status = GameSession.Status.REVEAL + self.session.save(update_fields=["status"]) + + stale_session = GameSession( + pk=self.session.pk, + host=self.host, + code=self.session.code, + status=GameSession.Status.GUESS, + current_round=self.session.current_round, + ) + mock_session_get.return_value = stale_session + + response = self.client.post( + reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question.id}), + data={ + "player_id": self.players[2].id, + "session_token": self.players[2].session_token, + "selected_text": "Shakespeare", + }, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["session"]["status"], GameSession.Status.REVEAL) + self.assertTrue(response.json()["phase_transition"]["auto_advanced"]) + self.assertIsNotNone(response.json()["reveal"]) + mock_resolve_scores.assert_not_called() + mock_sync_broadcast.assert_not_called() + self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 2) + class ScoreCalculationTests(TestCase): def setUp(self): diff --git a/lobby/views.py b/lobby/views.py index 7ab41ee..804404d 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -954,35 +954,59 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso leaderboard = None if players_count > 0 and guess_count >= players_count: - already_calculated = ScoreEvent.objects.filter( - session=session, - meta__round_question_id=round_question.id, - ).exists() - if not already_calculated: - score_events, leaderboard = _resolve_scores(session, round_question, round_config) - else: - score_events = list( - ScoreEvent.objects.filter(session=session, meta__round_question_id=round_question.id).select_related("player") - ) - leaderboard = _build_leaderboard(session) + score_events = [] + should_broadcast_scores = False + + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + + if locked_session.status == GameSession.Status.GUESS: + already_calculated = ScoreEvent.objects.filter( + session=locked_session, + meta__round_question_id=round_question.id, + ).exists() + if not already_calculated: + score_events, leaderboard = _resolve_scores(locked_session, round_question, round_config) + should_broadcast_scores = True + else: + score_events = list( + ScoreEvent.objects.filter( + session=locked_session, + meta__round_question_id=round_question.id, + ).select_related("player") + ) + leaderboard = _build_leaderboard(locked_session) + + locked_session.status = GameSession.Status.REVEAL + locked_session.save(update_fields=["status"]) + + elif locked_session.status == GameSession.Status.REVEAL: + score_events = list( + ScoreEvent.objects.filter( + session=locked_session, + meta__round_question_id=round_question.id, + ).select_related("player") + ) + leaderboard = _build_leaderboard(locked_session) + + session_status = locked_session.status - session.status = GameSession.Status.REVEAL - session.save(update_fields=["status"]) - session_status = session.status reveal_payload = _build_reveal_payload(round_question) - score_deltas = [ - {"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason} - for ev in score_events - ] - sync_broadcast_phase_event( - session.code, - "phase.scores_calculated", - { - "round_question_id": round_question.id, - "score_deltas": score_deltas, - "leaderboard": list(leaderboard), - }, - ) + + if should_broadcast_scores: + score_deltas = [ + {"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason} + for ev in score_events + ] + sync_broadcast_phase_event( + session.code, + "phase.scores_calculated", + { + "round_question_id": round_question.id, + "score_deltas": score_deltas, + "leaderboard": list(leaderboard), + }, + ) return JsonResponse( { From bfa4ab859c1893fa8062e7f82cd584e416d861be Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 03:01:02 +0000 Subject: [PATCH 6/8] fix(lobby): promote zero-score reveals to scoreboard --- lobby/tests.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++ lobby/views.py | 7 ------ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/lobby/tests.py b/lobby/tests.py index 081fa1c..2a64f4f 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -788,6 +788,68 @@ class CanonicalRoundFlowTests(TestCase): self.assertEqual([entry["nickname"] for entry in payload["scoreboard"]], ["Luna", "Nora", "Mads"]) self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare") + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._resolve_scores") + def test_session_detail_promotes_zero_score_event_reveal_to_scoreboard(self, mock_resolve_scores, mock_sync_broadcast): + self.client.login(username="host_canonical", password="secret123") + self.session.status = GameSession.Status.GUESS + self.session.save(update_fields=["status"]) + + round_config = RoundConfig.objects.create( + session=self.session, + number=1, + category=self.category, + points_correct=5, + points_bluff=2, + ) + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer="Shakespeare", + ) + LieAnswer.objects.create(round_question=round_question, player=self.players[0], text="Marlowe") + LieAnswer.objects.create(round_question=round_question, player=self.players[1], text="Chaucer") + LieAnswer.objects.create(round_question=round_question, player=self.players[2], text="Austen") + + mock_resolve_scores.return_value = ([], [ + {"id": player.id, "nickname": player.nickname, "score": player.score} + for player in sorted(self.players, key=lambda player: player.nickname) + ]) + + guess_targets = ["Shakespeare", "Shakespeare", "Shakespeare"] + guess_responses = [] + for player, selected_text in zip(self.players, guess_targets, strict=True): + guess_responses.append( + self.client.post( + reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question.id}), + data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text}, + content_type="application/json", + ) + ) + + self.assertTrue(all(response.status_code == 201 for response in guess_responses)) + self.assertEqual(guess_responses[-1].json()["session"]["status"], GameSession.Status.REVEAL) + self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 0) + + detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})) + + self.assertEqual(detail_response.status_code, 200) + payload = detail_response.json() + self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) + self.assertEqual(payload["phase_view_model"]["current_phase"], GameSession.Status.SCOREBOARD) + self.assertTrue(payload["phase_view_model"]["readiness"]["scoreboard_ready"]) + self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare") + mock_resolve_scores.assert_called_once_with(self.session, round_question, round_config) + mock_sync_broadcast.assert_any_call( + self.session.code, + "phase.scoreboard", + { + "leaderboard": payload["scoreboard"], + "current_round": self.session.current_round, + }, + ) + @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._resolve_scores") @patch("lobby.views.GameSession.objects.get") diff --git a/lobby/views.py b/lobby/views.py index 804404d..4146cb5 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -247,13 +247,6 @@ def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession: if current_round_question is None: return session - has_round_scores = ScoreEvent.objects.filter( - session=session, - meta__round_question_id=current_round_question.id, - ).exists() - if not has_round_scores: - return session - with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status != GameSession.Status.REVEAL: From 624bcd602b70380aa7d9bf046a46469e04c5540e Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 03:45:10 +0000 Subject: [PATCH 7/8] fix(lobby): gate reveal promotion on resolved rounds --- lobby/views.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lobby/views.py b/lobby/views.py index 4146cb5..f092fc9 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -247,6 +247,16 @@ def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession: 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: From 242aeaacd6b02c1908ab69e2e10bcd8abee28f3c Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 04:22:45 +0000 Subject: [PATCH 8/8] fix(lobby): avoid orphaned round configs on round start --- lobby/tests.py | 37 +++++++++++++++++++++++++++++++++++++ lobby/views.py | 22 +++++++++++++--------- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/lobby/tests.py b/lobby/tests.py index 2a64f4f..dead0e8 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -308,6 +308,23 @@ 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")) + 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") + + response = self.client.post( + reverse("lobby:start_round", kwargs={"code": self.session.code}), + data={"category_slug": self.category.slug}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error_code"], "no_available_questions") + self.session.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LOBBY) + self.assertFalse(RoundConfig.objects.filter(session=self.session, number=1).exists()) + self.assertFalse(RoundQuestion.objects.filter(session=self.session, round_number=1).exists()) + class LieSubmissionTests(TestCase): def setUp(self): @@ -1329,6 +1346,26 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(response.json()["locale"], "da") self.assertEqual(response.json()["error"], "Næste runde kan kun starte fra scoreboard-fasen") + def test_start_next_round_does_not_persist_round_config_when_question_selection_fails(self): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + self.next_question.delete() + + response = self.client.post( + reverse( + "lobby:start_next_round", + kwargs={"code": self.session.code}, + ) + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error_code"], "no_available_questions") + self.session.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) + self.assertEqual(self.session.current_round, 1) + self.assertFalse(RoundConfig.objects.filter(session=self.session, number=2).exists()) + self.assertFalse(RoundQuestion.objects.filter(session=self.session, round_number=2).exists()) + def test_reveal_scoreboard_unsupported_locale_falls_back_to_en_deterministically(self): self.client.login(username="other_reveal", password="secret123") diff --git a/lobby/views.py b/lobby/views.py index f092fc9..31024fa 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -528,23 +528,25 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: status=400, ) - round_config, created = RoundConfig.objects.get_or_create( - session=session, - number=session.current_round, - defaults={"category": category}, - ) - if not created: + 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"]) @@ -1101,22 +1103,24 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: if previous_round_config is None: return api_error(request, code="round_config_missing", status=400) - locked_session.current_round += 1 - next_round_config = RoundConfig.objects.create( + next_round_number = locked_session.current_round + 1 + next_round_config = RoundConfig( session=locked_session, - number=locked_session.current_round, + 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, ) + locked_session.current_round = next_round_number try: round_question = _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"])