feat(f3): add lie submission window with 45s default
This commit is contained in:
2
TODO.md
2
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)
|
||||
|
||||
19
fupogfakta/migrations/0002_roundquestion_shown_at.py
Normal file
19
fupogfakta/migrations/0002_roundquestion_shown_at.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
107
lobby/tests.py
107
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")
|
||||
|
||||
@@ -9,4 +9,10 @@ urlpatterns = [
|
||||
path("sessions/join", views.join_session, name="join_session"),
|
||||
path("sessions/<str:code>", views.session_detail, name="session_detail"),
|
||||
path("sessions/<str:code>/rounds/start", views.start_round, name="start_round"),
|
||||
path("sessions/<str:code>/questions/show", views.show_question, name="show_question"),
|
||||
path(
|
||||
"sessions/<str:code>/questions/<int:round_question_id>/lies/submit",
|
||||
views.submit_lie,
|
||||
name="submit_lie",
|
||||
),
|
||||
]
|
||||
|
||||
141
lobby/views.py
141
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user