diff --git a/TODO.md b/TODO.md index 0557ada..ce25e01 100644 --- a/TODO.md +++ b/TODO.md @@ -55,7 +55,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo ### Fase 3 — Spilflow `Fup og Fakta` - [x] Lobby: host opretter session, spillere joiner via kode - [x] Runde starter med kategori -- [ ] Spørgsmål vises -> alle skriver løgn inden X sek +- [x] Spørgsmål vises -> alle skriver løgn inden X sek - [ ] System blander korrekt svar + løgne - [ ] Guessfase: alle gætter inden Z sek - [ ] Pointudregning (konfigurerbar pr. runde) diff --git a/fupogfakta/migrations/0002_roundquestion_shown_at.py b/fupogfakta/migrations/0002_roundquestion_shown_at.py new file mode 100644 index 0000000..0a9ccb4 --- /dev/null +++ b/fupogfakta/migrations/0002_roundquestion_shown_at.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.2 on 2026-02-27 13:32 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("fupogfakta", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="roundquestion", + name="shown_at", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/fupogfakta/models.py b/fupogfakta/models.py index da9f309..1e69e7e 100644 --- a/fupogfakta/models.py +++ b/fupogfakta/models.py @@ -1,5 +1,6 @@ from django.db import models from django.contrib.auth import get_user_model +from django.utils import timezone User = get_user_model() @@ -85,6 +86,7 @@ class RoundQuestion(models.Model): round_number = models.PositiveIntegerField() question = models.ForeignKey(Question, on_delete=models.PROTECT) correct_answer = models.CharField(max_length=255) + shown_at = models.DateTimeField(default=timezone.now) class LieAnswer(models.Model): diff --git a/lobby/tests.py b/lobby/tests.py index ca49efa..d8f8a61 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1,8 +1,19 @@ +from datetime import timedelta + from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse +from django.utils import timezone -from fupogfakta.models import Category, GameSession, Player, Question, RoundConfig +from fupogfakta.models import ( + Category, + GameSession, + LieAnswer, + Player, + Question, + RoundConfig, + RoundQuestion, +) User = get_user_model() @@ -160,3 +171,97 @@ class StartRoundTests(TestCase): self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error"], "Round can only be started from lobby") + + +class LieSubmissionTests(TestCase): + def setUp(self): + self.host = User.objects.create_user(username="host", password="secret123") + self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE) + self.category = Category.objects.create(name="Geografi", slug="geografi", is_active=True) + self.question = Question.objects.create( + category=self.category, + prompt="Hvad er hovedstaden i Australien?", + correct_answer="Canberra", + is_active=True, + ) + RoundConfig.objects.create( + session=self.session, + number=1, + category=self.category, + lie_seconds=45, + ) + self.player = Player.objects.create(session=self.session, nickname="Luna") + + def test_host_can_show_question_and_get_lie_deadline(self): + self.client.login(username="host", password="secret123") + + response = self.client.post(reverse("lobby:show_question", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 201) + payload = response.json() + self.assertEqual(payload["config"]["lie_seconds"], 45) + self.assertIn("lie_deadline_at", payload["round_question"]) + self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists()) + + def test_player_can_submit_lie_before_deadline(self): + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + ) + + response = self.client.post( + reverse( + "lobby:submit_lie", + kwargs={"code": self.session.code, "round_question_id": round_question.id}, + ), + data={"player_id": self.player.id, "text": "Sydney"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 201) + self.assertTrue(LieAnswer.objects.filter(round_question=round_question, player=self.player).exists()) + + def test_submit_lie_rejects_after_time_window(self): + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + ) + round_question.shown_at = timezone.now() - timedelta(seconds=46) + round_question.save(update_fields=["shown_at"]) + + response = self.client.post( + reverse( + "lobby:submit_lie", + kwargs={"code": self.session.code, "round_question_id": round_question.id}, + ), + data={"player_id": self.player.id, "text": "Melbourne"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "Lie submission window has closed") + + def test_submit_lie_rejects_duplicate_submission(self): + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + ) + LieAnswer.objects.create(round_question=round_question, player=self.player, text="Perth") + + response = self.client.post( + reverse( + "lobby:submit_lie", + kwargs={"code": self.session.code, "round_question_id": round_question.id}, + ), + data={"player_id": self.player.id, "text": "Brisbane"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"], "Lie already submitted for this player") diff --git a/lobby/urls.py b/lobby/urls.py index a2b1c31..e36d542 100644 --- a/lobby/urls.py +++ b/lobby/urls.py @@ -9,4 +9,10 @@ urlpatterns = [ path("sessions/join", views.join_session, name="join_session"), path("sessions/", views.session_detail, name="session_detail"), path("sessions//rounds/start", views.start_round, name="start_round"), + path("sessions//questions/show", views.show_question, name="show_question"), + path( + "sessions//questions//lies/submit", + views.submit_lie, + name="submit_lie", + ), ] diff --git a/lobby/views.py b/lobby/views.py index aab0a6a..82e0ed3 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -1,12 +1,22 @@ import json import random +from datetime import timedelta from django.contrib.auth.decorators import login_required -from django.db import transaction +from django.db import IntegrityError, transaction from django.http import HttpRequest, JsonResponse +from django.utils import timezone from django.views.decorators.http import require_GET, require_POST -from fupogfakta.models import Category, GameSession, Player, Question, RoundConfig +from fupogfakta.models import ( + Category, + GameSession, + LieAnswer, + Player, + Question, + RoundConfig, + RoundQuestion, +) SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" SESSION_CODE_LENGTH = 6 @@ -198,3 +208,130 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: }, status=201, ) + + +@require_POST +@login_required +def show_question(request: HttpRequest, code: str) -> 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 show question"}, status=403) + + if session.status != GameSession.Status.LIE: + return JsonResponse({"error": "Question can only be shown in lie phase"}, status=400) + + try: + round_config = RoundConfig.objects.get(session=session, number=session.current_round) + except RoundConfig.DoesNotExist: + return JsonResponse({"error": "Round config missing"}, status=400) + + if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists(): + return JsonResponse({"error": "Question already shown for this round"}, status=409) + + used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True) + available_questions = Question.objects.filter( + category=round_config.category, + is_active=True, + ).exclude(pk__in=used_question_ids) + + if not available_questions.exists(): + return JsonResponse({"error": "No available questions in category"}, status=400) + + question = random.choice(list(available_questions)) + round_question = RoundQuestion.objects.create( + session=session, + round_number=session.current_round, + question=question, + correct_answer=question.correct_answer, + ) + + lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) + + return JsonResponse( + { + "round_question": { + "id": round_question.id, + "prompt": question.prompt, + "round_number": round_question.round_number, + "shown_at": round_question.shown_at.isoformat(), + "lie_deadline_at": lie_deadline_at.isoformat(), + }, + "config": { + "lie_seconds": round_config.lie_seconds, + }, + }, + status=201, + ) + + +@require_POST +def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: + payload = _json_body(request) + session_code = code.strip().upper() + + player_id = payload.get("player_id") + lie_text = str(payload.get("text", "")).strip() + + if not player_id: + return JsonResponse({"error": "player_id is required"}, status=400) + + if not lie_text or len(lie_text) > 255: + return JsonResponse({"error": "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.LIE: + return JsonResponse({"error": "Lie submission is only allowed in lie 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) + + lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) + if timezone.now() > lie_deadline_at: + return JsonResponse({"error": "Lie submission window has closed"}, status=400) + + try: + lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text) + except IntegrityError: + return JsonResponse({"error": "Lie already submitted for this player"}, status=409) + + return JsonResponse( + { + "lie": { + "id": lie.id, + "player_id": player.id, + "round_question_id": round_question.id, + "text": lie.text, + "created_at": lie.created_at.isoformat(), + }, + "window": { + "lie_deadline_at": lie_deadline_at.isoformat(), + }, + }, + status=201, + )