diff --git a/lobby/tests.py b/lobby/tests.py index d8f8a61..27bc07b 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -265,3 +265,61 @@ class LieSubmissionTests(TestCase): self.assertEqual(response.status_code, 409) self.assertEqual(response.json()["error"], "Lie already submitted for this player") + + +class GuessOptionMixingTests(TestCase): + def setUp(self): + self.host = User.objects.create_user(username="host_guess", password="secret123") + self.session = GameSession.objects.create(host=self.host, code="GU3551", status=GameSession.Status.LIE) + self.category = Category.objects.create(name="Film", slug="film", is_active=True) + self.question = Question.objects.create( + category=self.category, + prompt="Hvem instruerede Inception?", + correct_answer="Christopher Nolan", + is_active=True, + ) + RoundConfig.objects.create(session=self.session, number=1, category=self.category, guess_seconds=30) + self.round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + ) + self.p1 = Player.objects.create(session=self.session, nickname="Luna") + self.p2 = Player.objects.create(session=self.session, nickname="Bo") + self.p3 = Player.objects.create(session=self.session, nickname="Mia") + + LieAnswer.objects.create(round_question=self.round_question, player=self.p1, text="James Cameron") + LieAnswer.objects.create(round_question=self.round_question, player=self.p2, text=" james cameron ") + LieAnswer.objects.create(round_question=self.round_question, player=self.p3, text="Christopher Nolan") + + def _start_guess_phase(self, view_key: str): + self.client.login(username="host_guess", password="secret123") + return self.client.post( + reverse( + "lobby:start_guess_phase", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ), + data={"view_key": view_key}, + content_type="application/json", + ) + + def test_start_guess_phase_mixes_unique_correct_and_lies(self): + response = self._start_guess_phase("screen-main") + + self.assertEqual(response.status_code, 200) + body = response.json() + option_texts = [item["text"] for item in body["guess_options"]] + + self.assertCountEqual(option_texts, ["Christopher Nolan", "James Cameron"]) + + def test_start_guess_phase_is_deterministic_for_same_view_key(self): + first = self._start_guess_phase("screen-main") + self.session.refresh_from_db() + self.session.status = GameSession.Status.LIE + self.session.save(update_fields=["status"]) + second = self._start_guess_phase("screen-main") + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.json()["guess_options"], second.json()["guess_options"]) diff --git a/lobby/urls.py b/lobby/urls.py index e36d542..ae27f64 100644 --- a/lobby/urls.py +++ b/lobby/urls.py @@ -15,4 +15,9 @@ urlpatterns = [ views.submit_lie, name="submit_lie", ), + path( + "sessions//questions//guess/start", + views.start_guess_phase, + name="start_guess_phase", + ), ] diff --git a/lobby/views.py b/lobby/views.py index 82e0ed3..d8ed332 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -1,5 +1,7 @@ +import hashlib import json import random +import re from datetime import timedelta from django.contrib.auth.decorators import login_required @@ -43,6 +45,51 @@ def _generate_session_code() -> str: return "".join(random.choices(SESSION_CODE_ALPHABET, k=SESSION_CODE_LENGTH)) + + +def _normalize_answer_text(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().casefold() + + +def _build_guess_options(round_question: RoundQuestion, view_key: str = "host") -> list[dict]: + options_by_normalized_text: dict[str, dict] = {} + + correct_text = round_question.correct_answer.strip() + correct_normalized = _normalize_answer_text(correct_text) + options_by_normalized_text[correct_normalized] = { + "text": correct_text, + "kind": "correct", + "source_player_ids": [], + } + + for lie in round_question.lies.select_related("player").order_by("created_at", "id"): + lie_text = lie.text.strip() + if not lie_text: + continue + + normalized_text = _normalize_answer_text(lie_text) + existing = options_by_normalized_text.get(normalized_text) + + if existing: + existing["source_player_ids"].append(lie.player_id) + continue + + options_by_normalized_text[normalized_text] = { + "text": lie_text, + "kind": "lie", + "source_player_ids": [lie.player_id], + } + + options = list(options_by_normalized_text.values()) + seed_material = f"{round_question.id}:{view_key}".encode("utf-8") + seed = int(hashlib.sha256(seed_material).hexdigest()[:16], 16) + random.Random(seed).shuffle(options) + + for option in options: + option["source_player_ids"].sort() + + return options + def _create_unique_session_code() -> str: for _ in range(MAX_CODE_GENERATION_ATTEMPTS): code = _generate_session_code() @@ -335,3 +382,77 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR }, status=201, ) + + + +@require_POST +@login_required +def start_guess_phase(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: + payload = _json_body(request) + session_code = code.strip().upper() + raw_view_key = str(payload.get("view_key", "host")).strip() + view_key = raw_view_key[:64] if raw_view_key else "host" + + 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 start guess phase"}, status=403) + + if session.status != GameSession.Status.LIE: + return JsonResponse({"error": "Guess phase can only be started from lie phase"}, status=400) + + try: + round_question = RoundQuestion.objects.select_related("question").get( + pk=round_question_id, + session=session, + round_number=session.current_round, + ) + except RoundQuestion.DoesNotExist: + return JsonResponse({"error": "Round question not found"}, status=404) + + try: + round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) + except RoundConfig.DoesNotExist: + return JsonResponse({"error": "Round config missing"}, status=400) + + guess_options = _build_guess_options(round_question=round_question, view_key=view_key) + + if len(guess_options) < 2: + return JsonResponse({"error": "Not enough answers to start guess phase"}, status=400) + + with transaction.atomic(): + session = GameSession.objects.select_for_update().get(pk=session.pk) + if session.status != GameSession.Status.LIE: + return JsonResponse({"error": "Guess phase can only be started from lie phase"}, status=400) + + session.status = GameSession.Status.GUESS + session.save(update_fields=["status"]) + + guess_deadline_at = timezone.now() + timedelta(seconds=round_config.guess_seconds) + + return JsonResponse( + { + "session": { + "code": session.code, + "status": session.status, + "current_round": session.current_round, + }, + "round_question": { + "id": round_question.id, + "prompt": round_question.question.prompt, + }, + "config": { + "guess_seconds": round_config.guess_seconds, + "guess_deadline_at": guess_deadline_at.isoformat(), + }, + "guess_options": [{"text": option["text"]} for option in guess_options], + "debug": { + "options_total": len(guess_options), + "lies_submitted_total": round_question.lies.count(), + }, + }, + status=200, + )