Compare commits
10 Commits
93d3e9eca2
...
1662d7d197
| Author | SHA1 | Date | |
|---|---|---|---|
| 1662d7d197 | |||
| 1c78b856fb | |||
| 77dfefa9d4 | |||
| 03100c99cd | |||
| 9995203add | |||
| 2d9548b6de | |||
| 2f040c87fb | |||
| 811ef949eb | |||
| 534eb578a9 | |||
| 3bfa0f5b2e |
6
TODO.md
6
TODO.md
@@ -29,7 +29,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
|
||||
### Fase 0 — Scope + regler
|
||||
- [x] Fastlæg MVP for Spil 1 (`Fup og Fakta`) — se `docs/F0_MVP_FUP_OG_FAKTA.md`
|
||||
- [x] Midlertidige defaults sat (X/Z, spillerantal)
|
||||
- [ ] Fastlæg anti-cheat regler (fx ingen identiske løgne)
|
||||
- [x] Fastlæg anti-cheat regler (fx ingen identiske løgne) — se docs/F0_ANTI_CHEAT_RULES.md
|
||||
|
||||
### Fase 1 — Monorepo + Django skelet
|
||||
- [x] Opret Django-projekt (`partyhub`)
|
||||
@@ -53,8 +53,8 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
|
||||
- [x] `ScoreEvent` (auditérbar pointslog)
|
||||
|
||||
### Fase 3 — Spilflow `Fup og Fakta`
|
||||
- [ ] Lobby: host opretter session, spillere joiner via kode
|
||||
- [ ] Runde starter med kategori
|
||||
- [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
|
||||
- [ ] System blander korrekt svar + løgne
|
||||
- [ ] Guessfase: alle gætter inden Z sek
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"updatedAt": "2026-02-27T12:00:57Z",
|
||||
"updatedAt": "2026-02-27T13:15:06Z",
|
||||
"active": [],
|
||||
"queue": []
|
||||
}
|
||||
|
||||
53
docs/F0_ANTI_CHEAT_RULES.md
Normal file
53
docs/F0_ANTI_CHEAT_RULES.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# F0 anti-cheat regler — Fup og Fakta
|
||||
|
||||
## Formål
|
||||
Fastlæg simple, håndhævelige anti-cheat regler for F0, så runderne opleves fair uden tung NLP/moderation.
|
||||
|
||||
## Scope (F0)
|
||||
Gælder for inputfasen hvor spillere indsender løgne til et spørgsmål.
|
||||
|
||||
## Regelsæt (F0)
|
||||
|
||||
### 1) Ingen identiske løgne i samme runde
|
||||
- To spillere må ikke have samme løgntekst efter normalisering.
|
||||
- Hvis en indsendt løgn matcher en eksisterende løgn i runden, afvises den med fejlbesked og spilleren skal indsende ny tekst.
|
||||
|
||||
### 2) Ingen løgn må være identisk med korrekt svar
|
||||
- En løgn, der matcher det korrekte svar efter normalisering, afvises.
|
||||
|
||||
### 3) Ingen tomme eller trivielt ugyldige svar
|
||||
- Tom tekst, kun whitespace eller kun tegnsætning afvises.
|
||||
- Minimumslængde: 2 tegn efter trim.
|
||||
|
||||
### 4) Én aktiv løgn pr. spiller pr. spørgsmål
|
||||
- Spilleren må gerne overskrive sin egen løgn inden deadline.
|
||||
- Ved deadline er kun seneste gyldige version gældende.
|
||||
|
||||
### 5) Ingen afslørende metatekst
|
||||
- Svar som eksplicit afslører bluff-mekanikken (fx “det rigtige svar er …”, “jeg lyver”) afvises i F0 via enkel nøgleordsblokliste.
|
||||
|
||||
## Normalisering (bruges i regel 1 + 2)
|
||||
Følgende normalisering anvendes før sammenligning:
|
||||
1. Unicode NFKC
|
||||
2. Trim + collapse af flere mellemrum til ét
|
||||
3. Lowercase
|
||||
4. Fjern afsluttende tegnsætning (`.`, `,`, `!`, `?`, `:` `;`)
|
||||
|
||||
Bemærk: F0 bruger **ikke** semantisk duplikatdetektion (synonymer/stavevarianter kan passere).
|
||||
|
||||
## Håndhævelse i UX/API
|
||||
- Validering sker server-side ved submit.
|
||||
- Klienten får konkret fejlårsag og kan indsende igen inden tidsfrist.
|
||||
- Samme valideringsregler gælder for alle klienter (web/mobil).
|
||||
|
||||
## Acceptance criteria (F0)
|
||||
- Identiske løgne (efter normalisering) kan ikke gemmes i samme runde.
|
||||
- Løgn == korrekt svar (efter normalisering) kan ikke gemmes.
|
||||
- Tom/ugyldig input afvises.
|
||||
- Overskrivning af egen løgn inden deadline virker.
|
||||
- Ved deadline bruges kun seneste gyldige løgn.
|
||||
|
||||
## Out-of-scope (bevidst udskudt)
|
||||
- Semantisk duplikatdetektion (embeddings/fuzzy matching)
|
||||
- Avanceret toxicitet/moderation
|
||||
- Sprogdetektion og translitterering
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-27 13:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fupogfakta', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='roundquestion',
|
||||
name='lie_deadline_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='roundquestion',
|
||||
unique_together={('session', 'round_number')},
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -85,6 +85,10 @@ class RoundQuestion(models.Model):
|
||||
round_number = models.PositiveIntegerField()
|
||||
question = models.ForeignKey(Question, on_delete=models.PROTECT)
|
||||
correct_answer = models.CharField(max_length=255)
|
||||
lie_deadline_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = (("session", "round_number"),)
|
||||
|
||||
|
||||
class LieAnswer(models.Model):
|
||||
|
||||
@@ -1,3 +1,93 @@
|
||||
from django.test import TestCase
|
||||
from datetime import timedelta
|
||||
|
||||
# Create your tests here.
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Category, GameSession, LieAnswer, Player, Question, RoundConfig, RoundQuestion
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class LiePhaseFlowTests(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="Historie", slug="historie", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvilket år faldt muren?",
|
||||
correct_answer="1989",
|
||||
is_active=True,
|
||||
)
|
||||
RoundConfig.objects.create(
|
||||
session=self.session,
|
||||
number=self.session.current_round,
|
||||
category=self.category,
|
||||
lie_seconds=20,
|
||||
)
|
||||
self.player = Player.objects.create(session=self.session, nickname="Luna")
|
||||
|
||||
def test_host_can_start_lie_question_with_deadline(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(reverse("fupogfakta:start_lie_question", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
payload = response.json()["question"]
|
||||
self.assertEqual(payload["round"], 1)
|
||||
self.assertEqual(payload["prompt"], self.question.prompt)
|
||||
self.assertEqual(payload["lie_seconds"], 20)
|
||||
|
||||
round_question = RoundQuestion.objects.get(session=self.session, round_number=1)
|
||||
self.assertIsNotNone(round_question.lie_deadline_at)
|
||||
|
||||
def test_submit_lie_before_deadline_is_accepted(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
lie_deadline_at=timezone.now() + timedelta(seconds=10),
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("fupogfakta:submit_lie", kwargs={"code": self.session.code}),
|
||||
data={"player_id": self.player.id, "text": "1991"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(
|
||||
LieAnswer.objects.filter(round_question=round_question, player=self.player, text="1991").exists()
|
||||
)
|
||||
self.assertEqual(response.json()["progress"]["submitted_count"], 1)
|
||||
|
||||
def test_submit_lie_after_deadline_is_rejected(self):
|
||||
RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
lie_deadline_at=timezone.now() - timedelta(seconds=1),
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("fupogfakta:submit_lie", kwargs={"code": self.session.code}),
|
||||
data={"player_id": self.player.id, "text": "1991"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Lie timer expired")
|
||||
self.assertEqual(LieAnswer.objects.count(), 0)
|
||||
|
||||
def test_non_host_cannot_start_lie_question(self):
|
||||
User.objects.create_user(username="other", password="secret123")
|
||||
self.client.login(username="other", password="secret123")
|
||||
|
||||
response = self.client.post(reverse("fupogfakta:start_lie_question", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only host can start lie question")
|
||||
|
||||
10
fupogfakta/urls.py
Normal file
10
fupogfakta/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "fupogfakta"
|
||||
|
||||
urlpatterns = [
|
||||
path("sessions/<str:code>/lie-question/start", views.start_lie_question, name="start_lie_question"),
|
||||
path("sessions/<str:code>/lies", views.submit_lie, name="submit_lie"),
|
||||
]
|
||||
@@ -1,3 +1,176 @@
|
||||
from django.shortcuts import render
|
||||
import json
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
# Create your views here.
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from .models import GameSession, LieAnswer, Player, Question, RoundConfig, RoundQuestion
|
||||
|
||||
|
||||
MAX_LIE_LENGTH = 255
|
||||
|
||||
|
||||
def _json_body(request: HttpRequest) -> dict:
|
||||
if not request.body:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def _emit_realtime_session_event(session_code: str, event: str, payload: dict) -> None:
|
||||
channel_layer = get_channel_layer()
|
||||
if not channel_layer:
|
||||
return
|
||||
|
||||
try:
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
f"session_{session_code}",
|
||||
{
|
||||
"type": "session.event",
|
||||
"event": event,
|
||||
"payload": payload,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
# Realtime broadcasting must not fail game flow.
|
||||
return
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def start_lie_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 start lie question"}, status=403)
|
||||
|
||||
if session.status != GameSession.Status.LIE:
|
||||
return JsonResponse({"error": "Session is not in lie 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": "Session is not in lie phase"}, status=400)
|
||||
|
||||
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
|
||||
return JsonResponse({"error": "Lie question already started for round"}, status=409)
|
||||
|
||||
try:
|
||||
round_config = RoundConfig.objects.select_related("category").get(
|
||||
session=session,
|
||||
number=session.current_round,
|
||||
)
|
||||
except RoundConfig.DoesNotExist:
|
||||
return JsonResponse({"error": "Round config not found"}, status=404)
|
||||
|
||||
question_qs = Question.objects.filter(category=round_config.category, is_active=True)
|
||||
question_ids = list(question_qs.values_list("id", flat=True))
|
||||
if not question_ids:
|
||||
return JsonResponse({"error": "No active question for category"}, status=400)
|
||||
|
||||
question = question_qs.get(pk=random.choice(question_ids))
|
||||
|
||||
lie_deadline_at = timezone.now() + timedelta(seconds=round_config.lie_seconds)
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=session,
|
||||
round_number=session.current_round,
|
||||
question=question,
|
||||
correct_answer=question.correct_answer,
|
||||
lie_deadline_at=lie_deadline_at,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"round": round_question.round_number,
|
||||
"question_id": round_question.id,
|
||||
"prompt": round_question.question.prompt,
|
||||
"lie_deadline_at": round_question.lie_deadline_at.isoformat(),
|
||||
"lie_seconds": round_config.lie_seconds,
|
||||
}
|
||||
_emit_realtime_session_event(session.code, "lie_question_started", payload)
|
||||
|
||||
return JsonResponse({"question": payload}, status=201)
|
||||
|
||||
|
||||
@require_POST
|
||||
def submit_lie(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
payload = _json_body(request)
|
||||
|
||||
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 len(lie_text) < 2 or len(lie_text) > MAX_LIE_LENGTH:
|
||||
return JsonResponse({"error": "text must be between 2 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": "Session is not in lie phase"}, status=400)
|
||||
|
||||
try:
|
||||
player = Player.objects.get(id=player_id, session=session)
|
||||
except Player.DoesNotExist:
|
||||
return JsonResponse({"error": "Player not found in session"}, status=404)
|
||||
|
||||
try:
|
||||
round_question = RoundQuestion.objects.get(session=session, round_number=session.current_round)
|
||||
except RoundQuestion.DoesNotExist:
|
||||
return JsonResponse({"error": "No active lie question"}, status=404)
|
||||
|
||||
now = timezone.now()
|
||||
if round_question.lie_deadline_at and now > round_question.lie_deadline_at:
|
||||
return JsonResponse({"error": "Lie timer expired"}, status=400)
|
||||
|
||||
lie_answer, _ = LieAnswer.objects.update_or_create(
|
||||
round_question=round_question,
|
||||
player=player,
|
||||
defaults={"text": lie_text},
|
||||
)
|
||||
|
||||
submitted_count = LieAnswer.objects.filter(round_question=round_question).count()
|
||||
players_count = session.players.count()
|
||||
|
||||
event_payload = {
|
||||
"round": session.current_round,
|
||||
"question_id": round_question.id,
|
||||
"player_id": player.id,
|
||||
"submitted_count": submitted_count,
|
||||
"players_count": players_count,
|
||||
"lie_deadline_at": round_question.lie_deadline_at.isoformat() if round_question.lie_deadline_at else None,
|
||||
}
|
||||
_emit_realtime_session_event(session.code, "lie_submitted", event_payload)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"lie": {
|
||||
"id": lie_answer.id,
|
||||
"player_id": player.id,
|
||||
"question_id": round_question.id,
|
||||
},
|
||||
"progress": {
|
||||
"submitted_count": submitted_count,
|
||||
"players_count": players_count,
|
||||
},
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
161
lobby/tests.py
161
lobby/tests.py
@@ -1,3 +1,162 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
# Create your tests here.
|
||||
from fupogfakta.models import Category, GameSession, Player, Question, RoundConfig
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class LobbyFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host", password="secret123")
|
||||
|
||||
def test_create_session_requires_login(self):
|
||||
response = self.client.post(reverse("lobby:create_session"))
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(GameSession.objects.count(), 0)
|
||||
|
||||
def test_host_can_create_session(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(reverse("lobby:create_session"))
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
body = response.json()
|
||||
self.assertEqual(body["session"]["status"], GameSession.Status.LOBBY)
|
||||
self.assertEqual(len(body["session"]["code"]), 6)
|
||||
|
||||
session = GameSession.objects.get(code=body["session"]["code"])
|
||||
self.assertEqual(session.host, self.host)
|
||||
|
||||
def test_player_can_join_with_code(self):
|
||||
session = GameSession.objects.create(host=self.host, code="ABCD23")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:join_session"),
|
||||
data={"code": "abcd23", "nickname": "Luna"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
body = response.json()
|
||||
self.assertEqual(body["session"]["code"], "ABCD23")
|
||||
self.assertEqual(body["player"]["nickname"], "Luna")
|
||||
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
|
||||
|
||||
def test_join_rejects_duplicate_nickname_case_insensitive(self):
|
||||
session = GameSession.objects.create(host=self.host, code="QWER12")
|
||||
Player.objects.create(session=session, nickname="Luna")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:join_session"),
|
||||
data={"code": "QWER12", "nickname": "lUna"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"], "Nickname already taken")
|
||||
|
||||
def test_join_rejects_non_joinable_session(self):
|
||||
GameSession.objects.create(host=self.host, code="ZXCV98", status=GameSession.Status.FINISHED)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:join_session"),
|
||||
data={"code": "ZXCV98", "nickname": "Kai"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Session is not joinable")
|
||||
|
||||
def test_session_detail_returns_players(self):
|
||||
session = GameSession.objects.create(host=self.host, code="LMNO45")
|
||||
Player.objects.create(session=session, nickname="Mia", score=7)
|
||||
Player.objects.create(session=session, nickname="Bo", score=2)
|
||||
|
||||
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": "lmno45"}))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["session"]["players_count"], 2)
|
||||
self.assertEqual([p["nickname"] for p in payload["players"]], ["Bo", "Mia"])
|
||||
|
||||
|
||||
class StartRoundTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host", password="secret123")
|
||||
self.other_user = User.objects.create_user(username="other", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="ABCD23")
|
||||
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
|
||||
Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvilket år faldt muren?",
|
||||
correct_answer="1989",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_host_can_start_round_with_selected_category(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||
data={"category_slug": self.category.slug},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
body = response.json()
|
||||
self.assertEqual(body["session"]["status"], GameSession.Status.LIE)
|
||||
self.assertEqual(body["round"]["number"], 1)
|
||||
self.assertEqual(body["round"]["category"]["slug"], self.category.slug)
|
||||
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||
round_config = RoundConfig.objects.get(session=self.session, number=1)
|
||||
self.assertEqual(round_config.category, self.category)
|
||||
|
||||
def test_start_round_requires_host(self):
|
||||
self.client.login(username="other", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||
data={"category_slug": self.category.slug},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only host can start round")
|
||||
|
||||
def test_start_round_requires_existing_active_category_with_questions(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||
data={"category_slug": "ukendt"},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
empty_category = Category.objects.create(name="Sport", slug="sport", is_active=True)
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||
data={"category_slug": empty_category.slug},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Category has no active questions")
|
||||
|
||||
def test_start_round_rejects_non_lobby_session(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
self.session.status = GameSession.Status.GUESS
|
||||
self.session.save(update_fields=["status"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||
data={"category_slug": self.category.slug},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Round can only be started from lobby")
|
||||
|
||||
@@ -8,4 +8,5 @@ urlpatterns = [
|
||||
path("sessions/create", views.create_session, name="create_session"),
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -2,10 +2,11 @@ import json
|
||||
import random
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from fupogfakta.models import GameSession, Player
|
||||
from fupogfakta.models import Category, GameSession, Player, Question, RoundConfig
|
||||
|
||||
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
SESSION_CODE_LENGTH = 6
|
||||
@@ -132,3 +133,68 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
"players": players,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
category_slug = str(payload.get("category_slug", "")).strip()
|
||||
|
||||
if not category_slug:
|
||||
return JsonResponse({"error": "category_slug is required"}, status=400)
|
||||
|
||||
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 start round"}, status=403)
|
||||
|
||||
if session.status != GameSession.Status.LOBBY:
|
||||
return JsonResponse({"error": "Round can only be started from lobby"}, status=400)
|
||||
|
||||
try:
|
||||
category = Category.objects.get(slug=category_slug, is_active=True)
|
||||
except Category.DoesNotExist:
|
||||
return JsonResponse({"error": "Category not found"}, status=404)
|
||||
|
||||
if not Question.objects.filter(category=category, is_active=True).exists():
|
||||
return JsonResponse({"error": "Category has no active questions"}, status=400)
|
||||
|
||||
with transaction.atomic():
|
||||
session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if session.status != GameSession.Status.LOBBY:
|
||||
return JsonResponse({"error": "Round can only be started from lobby"}, status=400)
|
||||
|
||||
round_config, created = RoundConfig.objects.get_or_create(
|
||||
session=session,
|
||||
number=session.current_round,
|
||||
defaults={"category": category},
|
||||
)
|
||||
if not created:
|
||||
return JsonResponse({"error": "Round already configured"}, status=409)
|
||||
|
||||
session.status = GameSession.Status.LIE
|
||||
session.save(update_fields=["status"])
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": session.status,
|
||||
"current_round": session.current_round,
|
||||
},
|
||||
"round": {
|
||||
"number": round_config.number,
|
||||
"category": {
|
||||
"slug": round_config.category.slug,
|
||||
"name": round_config.category.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
@@ -11,4 +11,5 @@ urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("healthz", health, name="healthz"),
|
||||
path("lobby/", include("lobby.urls")),
|
||||
path("game/", include("fupogfakta.urls")),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user