feat(f3): deterministic robust mix of correct answer and lies

This commit is contained in:
2026-02-27 15:51:11 +01:00
parent 9ed5a909f1
commit 2d56dc64af
3 changed files with 184 additions and 0 deletions

View File

@@ -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"])

View File

@@ -15,4 +15,9 @@ urlpatterns = [
views.submit_lie,
name="submit_lie",
),
path(
"sessions/<str:code>/questions/<int:round_question_id>/guess/start",
views.start_guess_phase,
name="start_guess_phase",
),
]

View File

@@ -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,
)