From adbdf5c87601cecbca6a29cd76ef74a3ae729ac4 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Fri, 27 Feb 2026 16:18:30 +0100 Subject: [PATCH] feat(f3): mix correct answer with lies and open guess phase --- TODO.md | 3 ++- lobby/admin.py | 1 - lobby/models.py | 1 - lobby/tests.py | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ lobby/urls.py | 5 ++++ lobby/views.py | 63 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index ef887e9..10fb2fe 100644 --- a/TODO.md +++ b/TODO.md @@ -56,7 +56,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo - [x] Lobby: host opretter session, spillere joiner via kode - [x] Runde starter med kategori - [x] Spørgsmål vises -> alle skriver løgn inden X sek -- [ ] System blander korrekt svar + løgne +- [x] System blander korrekt svar + løgne - [ ] Guessfase: alle gætter inden Z sek - [ ] Pointudregning (konfigurerbar pr. runde) - [ ] Scoreboard + næste spørgsmål/runde @@ -103,6 +103,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo - [ ] Migrations + static + health checks ### Backlog — Need-to-have / Nice-to-have +- [ ] (Need-to-have) Persistér mixed svarrækkefølge pr. round question, så alle spillere ser samme rækkefølge ved reconnect/refresh - [ ] (Need-to-have) Tilføj spiller-auth/session-token for submit_lie (pt. baseret på player_id i payload) - [ ] (Nice-to-have) Endpoint til status/progress i løgnfasen (antal indsendt ud af total) - [ ] (Need-to-have) [Fejltype: CI/lint F401] [Fil/område: core_admin/*, fupogfakta/tests.py+views.py, lobby/admin.py+models.py, realtime/*, voice/*] [Branch/PR: feature/f3-lobby-create-join, feature/fase0-mvp-fup-og-fakta, feature/lobby-mvp (ingen åbne PRs fundet)] Fjern ubrugte scaffold-imports (eller kør ruff check --fix) så quality gate kan blive grøn før merge. diff --git a/lobby/admin.py b/lobby/admin.py index 8c38f3f..b97a94f 100644 --- a/lobby/admin.py +++ b/lobby/admin.py @@ -1,3 +1,2 @@ -from django.contrib import admin # Register your models here. diff --git a/lobby/models.py b/lobby/models.py index 71a8362..35e0d64 100644 --- a/lobby/models.py +++ b/lobby/models.py @@ -1,3 +1,2 @@ -from django.db import models # Create your models here. diff --git a/lobby/tests.py b/lobby/tests.py index d8f8a61..2a0baeb 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -265,3 +265,75 @@ class LieSubmissionTests(TestCase): self.assertEqual(response.status_code, 409) self.assertEqual(response.json()["error"], "Lie already submitted for this player") + +class MixAnswersTests(TestCase): + def setUp(self): + self.host = User.objects.create_user(username="host", password="secret123") + self.other_user = User.objects.create_user(username="other", password="secret123") + self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE) + self.category = Category.objects.create(name="Historie", slug="historie", is_active=True) + self.question = Question.objects.create( + category=self.category, + prompt="Hvilken by er Danmarks hovedstad?", + correct_answer="København", + is_active=True, + ) + 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="København", + ) + self.player_one = Player.objects.create(session=self.session, nickname="Luna") + self.player_two = Player.objects.create(session=self.session, nickname="Mads") + + def test_host_can_mix_answers_and_transition_to_guess(self): + LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus") + LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense") + + self.client.login(username="host", password="secret123") + response = self.client.post( + reverse( + "lobby:mix_answers", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ) + ) + + self.assertEqual(response.status_code, 200) + payload = response.json() + answer_texts = [entry["text"] for entry in payload["answers"]] + self.assertEqual(set(answer_texts), {"København", "Aarhus", "Odense"}) + self.assertEqual(payload["session"]["status"], GameSession.Status.GUESS) + + self.session.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.GUESS) + + def test_mix_answers_requires_host(self): + self.client.login(username="other", password="secret123") + + response = self.client.post( + reverse( + "lobby:mix_answers", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ) + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"], "Only host can mix answers") + + def test_mix_answers_deduplicates_case_insensitive_lies(self): + LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="københavn") + LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Aarhus") + + self.client.login(username="host", password="secret123") + response = self.client.post( + reverse( + "lobby:mix_answers", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ) + ) + + self.assertEqual(response.status_code, 200) + answer_texts = [entry["text"] for entry in response.json()["answers"]] + self.assertEqual(set(answer_texts), {"København", "Aarhus"}) diff --git a/lobby/urls.py b/lobby/urls.py index e36d542..19ff715 100644 --- a/lobby/urls.py +++ b/lobby/urls.py @@ -15,4 +15,9 @@ urlpatterns = [ views.submit_lie, name="submit_lie", ), + path( + "sessions//questions//answers/mix", + views.mix_answers, + name="mix_answers", + ), ] diff --git a/lobby/views.py b/lobby/views.py index 82e0ed3..a32d6b8 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -335,3 +335,66 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR }, status=201, ) + +@require_POST +@login_required +def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: + session_code = code.strip().upper() + + try: + session = GameSession.objects.get(code=session_code) + except GameSession.DoesNotExist: + return JsonResponse({"error": "Session not found"}, status=404) + + if session.host_id != request.user.id: + return JsonResponse({"error": "Only host can mix answers"}, status=403) + + if session.status != GameSession.Status.LIE: + return JsonResponse({"error": "Answers can only be mixed in lie phase"}, status=400) + + try: + round_question = RoundQuestion.objects.get( + pk=round_question_id, + session=session, + round_number=session.current_round, + ) + except RoundQuestion.DoesNotExist: + return JsonResponse({"error": "Round question not found"}, status=404) + + lie_texts = list(round_question.lies.values_list("text", flat=True)) + + deduped_answers = [] + 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: + return JsonResponse({"error": "Not enough answers to mix"}, status=400) + + random.shuffle(deduped_answers) + + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + if locked_session.status != GameSession.Status.LIE: + return JsonResponse({"error": "Answers can only be mixed in lie phase"}, status=400) + locked_session.status = GameSession.Status.GUESS + locked_session.save(update_fields=["status"]) + + return JsonResponse( + { + "session": { + "code": session.code, + "status": GameSession.Status.GUESS, + "current_round": session.current_round, + }, + "round_question": { + "id": round_question.id, + "round_number": round_question.round_number, + }, + "answers": [{"text": text} for text in deduped_answers], + } + )