feat(f3): deterministic robust mix of correct answer and lies
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
121
lobby/views.py
121
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user