10 Commits

12 changed files with 590 additions and 11 deletions

View File

@@ -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

View File

@@ -1,5 +1,5 @@
{
"updatedAt": "2026-02-27T12:00:57Z",
"updatedAt": "2026-02-27T13:15:06Z",
"active": [],
"queue": []
}

View 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

View File

@@ -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')},
),
]

View File

@@ -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):

View File

@@ -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
View 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"),
]

View File

@@ -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,
)

View File

@@ -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")

View File

@@ -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"),
]

View File

@@ -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,
)

View File

@@ -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")),
]