feat(f3): add guess submission endpoint with deadline checks

This commit is contained in:
2026-02-27 16:31:31 +01:00
parent 3ee478f094
commit d66c21ecb3
4 changed files with 211 additions and 1 deletions

View File

@@ -57,7 +57,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- [x] Runde starter med kategori
- [x] Spørgsmål vises -> alle skriver løgn inden X sek
- [x] System blander korrekt svar + løgne
- [ ] Guessfase: alle gætter inden Z sek
- [x] Guessfase: alle gætter inden Z sek
- [ ] Pointudregning (konfigurerbar pr. runde)
- [ ] Scoreboard + næste spørgsmål/runde
- [ ] Slutresultat

View File

@@ -8,6 +8,7 @@ from django.utils import timezone
from fupogfakta.models import (
Category,
GameSession,
Guess,
LieAnswer,
Player,
Question,
@@ -337,3 +338,108 @@ class MixAnswersTests(TestCase):
self.assertEqual(response.status_code, 200)
answer_texts = [entry["text"] for entry in response.json()["answers"]]
self.assertEqual(set(answer_texts), {"København", "Aarhus"})
class GuessSubmissionTests(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.GUESS)
self.category = Category.objects.create(name="Videnskab", slug="videnskab", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Hvilken planet kaldes den røde planet?",
correct_answer="Mars",
is_active=True,
)
self.round_config = RoundConfig.objects.create(
session=self.session,
number=1,
category=self.category,
lie_seconds=45,
guess_seconds=30,
)
self.round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer="Mars",
)
self.player = Player.objects.create(session=self.session, nickname="Luna")
self.liar = Player.objects.create(session=self.session, nickname="Mads")
LieAnswer.objects.create(round_question=self.round_question, player=self.liar, text="Jupiter")
def test_player_can_submit_guess_in_guess_phase(self):
response = self.client.post(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "selected_text": "Mars"},
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
payload = response.json()
self.assertTrue(payload["guess"]["is_correct"])
self.assertIsNone(payload["guess"]["fooled_player_id"])
self.assertIn("guess_deadline_at", payload["window"])
def test_submit_guess_rejects_when_not_in_guess_phase(self):
self.session.status = GameSession.Status.LIE
self.session.save(update_fields=["status"])
response = self.client.post(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "selected_text": "Mars"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase")
def test_submit_guess_rejects_unknown_answer(self):
response = self.client.post(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "selected_text": "Venus"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Selected answer is not part of this round")
def test_submit_guess_rejects_duplicate_submission(self):
Guess.objects.create(round_question=self.round_question, player=self.player, selected_text="Mars", is_correct=True)
response = self.client.post(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "selected_text": "Jupiter"},
content_type="application/json",
)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"], "Guess already submitted for this player")
def test_submit_guess_rejects_after_deadline(self):
self.round_question.shown_at = timezone.now() - timedelta(seconds=76)
self.round_question.save(update_fields=["shown_at"])
response = self.client.post(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "selected_text": "Mars"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Guess submission window has closed")

View File

@@ -20,4 +20,10 @@ urlpatterns = [
views.mix_answers,
name="mix_answers",
),
path(
"sessions/<str:code>/questions/<int:round_question_id>/guesses/submit",
views.submit_guess,
name="submit_guess",
),
]

View File

@@ -11,6 +11,7 @@ from django.views.decorators.http import require_GET, require_POST
from fupogfakta.models import (
Category,
GameSession,
Guess,
LieAnswer,
Player,
Question,
@@ -398,3 +399,100 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
"answers": [{"text": text} for text in deduped_answers],
}
)
@require_POST
def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
payload = _json_body(request)
session_code = code.strip().upper()
player_id = payload.get("player_id")
selected_text = str(payload.get("selected_text", "")).strip()
if not player_id:
return JsonResponse({"error": "player_id is required"}, status=400)
if not selected_text or len(selected_text) > 255:
return JsonResponse({"error": "selected_text must be between 1 and 255 characters"}, status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.status != GameSession.Status.GUESS:
return JsonResponse({"error": "Guess submission is only allowed in guess phase"}, status=400)
try:
player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist:
return JsonResponse({"error": "Player not found in session"}, status=404)
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)
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_deadline_at = round_question.shown_at + timedelta(
seconds=round_config.lie_seconds + round_config.guess_seconds
)
if timezone.now() > guess_deadline_at:
return JsonResponse({"error": "Guess submission window has closed"}, status=400)
allowed_answers = {
round_question.correct_answer.strip().casefold(),
*(
text.strip().casefold()
for text in round_question.lies.values_list("text", flat=True)
if text.strip()
),
}
selected_normalized = selected_text.casefold()
if selected_normalized not in allowed_answers:
return JsonResponse({"error": "Selected answer is not part of this round"}, status=400)
correct_normalized = round_question.correct_answer.strip().casefold()
fooled_player_id = None
if selected_normalized != correct_normalized:
fooled_player_id = (
round_question.lies.filter(text__iexact=selected_text).values_list("player_id", flat=True).first()
)
try:
guess = Guess.objects.create(
round_question=round_question,
player=player,
selected_text=selected_text,
is_correct=selected_normalized == correct_normalized,
fooled_player_id=fooled_player_id,
)
except IntegrityError:
return JsonResponse({"error": "Guess already submitted for this player"}, status=409)
return JsonResponse(
{
"guess": {
"id": guess.id,
"player_id": player.id,
"round_question_id": round_question.id,
"selected_text": guess.selected_text,
"is_correct": guess.is_correct,
"fooled_player_id": guess.fooled_player_id,
"created_at": guess.created_at.isoformat(),
},
"window": {
"guess_deadline_at": guess_deadline_at.isoformat(),
},
},
status=201,
)