feature/f3-lobby-create-join: automated PR #2
2
TODO.md
2
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`)
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
{
|
||||
"updatedAt": "2026-02-27T12:00:57Z",
|
||||
"active": [],
|
||||
"updatedAt": "2026-02-27T12:30:04Z",
|
||||
"active": [
|
||||
{
|
||||
"taskId": "F3-LOBBY-CREATE-JOIN",
|
||||
"title": "Lobby: host opretter session, spillere joiner via kode",
|
||||
"branch": "feature/f3-lobby-create-join",
|
||||
"ownerSession": "agent:main:cron:1d0d2ab8-9121-4c2a-abc3-a646c38b3287",
|
||||
"ownerRole": "dev-runner",
|
||||
"status": "ready_for_merge",
|
||||
"startedAt": "2026-02-27T12:29:05.834537Z",
|
||||
"heartbeatAt": "2026-02-27T12:30:04Z"
|
||||
}
|
||||
],
|
||||
"queue": []
|
||||
}
|
||||
|
||||
57
docs/F0_ANTI_CHEAT_RULES.md
Normal file
57
docs/F0_ANTI_CHEAT_RULES.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# F0 anti-cheat regler — Fup og Fakta
|
||||
|
||||
## Formål
|
||||
Definere en simpel, håndterbar anti-cheat baseline til MVP, så runder forbliver fair uden avanceret NLP.
|
||||
|
||||
## Principper (F0)
|
||||
- Regler håndhæves server-side.
|
||||
- Validering sker ved submit (ikke kun i UI).
|
||||
- Ved regelbrud gives tydelig fejlbesked, og spilleren kan indsende igen inden timeout.
|
||||
- Hvis tiden udløber uden gyldigt svar, håndteres spilleren som intet svar.
|
||||
|
||||
## Regler for løgn-svar (submit-fasen)
|
||||
1. Én løgn pr. spiller pr. spørgsmål
|
||||
- Seneste gyldige submit inden timeout er gældende.
|
||||
|
||||
2. Ingen tomme eller trivielle svar
|
||||
- Afvis tom streng og kun-whitespace.
|
||||
- Afvis meget korte svar (<2 tegn efter trim).
|
||||
|
||||
3. Ingen identiske løgne mellem spillere
|
||||
- Sammenlign på normaliseret form:
|
||||
- trim whitespace
|
||||
- lowercase
|
||||
- kollaps flere mellemrum til ét
|
||||
- Hvis to spillere sender samme normaliserede tekst, accepteres den først modtagne; senere submit afvises med fejl.
|
||||
|
||||
4. Løgnen må ikke være identisk med korrekt svar
|
||||
- Samme normalisering som ovenfor.
|
||||
- Identisk med facit afvises.
|
||||
|
||||
5. Ingen direkte spoof af korrekt svar-markør
|
||||
- Systemet ejer præsentation af korrekt svar.
|
||||
- Klientinput må ikke kunne sætte metadata/flag, der markerer et svar som facit.
|
||||
|
||||
## Regler for gæt (guess-fasen)
|
||||
1. Man kan ikke vælge sit eget løgn-svar
|
||||
- Egne svar vises ikke som valgbare for spilleren.
|
||||
- Server validerer også dette.
|
||||
|
||||
2. Én gyldig stemme pr. spiller pr. spørgsmål
|
||||
- Seneste gyldige valg inden timeout er gældende.
|
||||
|
||||
## Drift og fairness
|
||||
- Log afviste submits med årsag (f.eks. duplicate_lie, matches_truth, too_short) til audit/debug.
|
||||
- Brug server-tid til fasegrænser; klienttid er udelukkende visning.
|
||||
|
||||
## Out-of-scope i F0 (senere fase)
|
||||
- Semantisk duplikatdetektion (f.eks. København vs kbh).
|
||||
- Toxicity/profanity-filter.
|
||||
- Avanceret collusion-detektion på tværs af runder.
|
||||
- IP/device-fingerprinting og anti-smurf.
|
||||
|
||||
## Acceptance criteria (F0 anti-cheat)
|
||||
- Identiske normaliserede løgne kan ikke sameksistere i samme spørgsmål.
|
||||
- Korrekt svar kan ikke indsendes som løgn.
|
||||
- Eget løgn-svar kan ikke vælges i guess-fasen.
|
||||
- Regelbrud håndhæves server-side, uanset klientadfærd.
|
||||
@@ -1,3 +1,83 @@
|
||||
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 GameSession, Player
|
||||
|
||||
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"])
|
||||
|
||||
11
lobby/urls.py
Normal file
11
lobby/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "lobby"
|
||||
|
||||
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"),
|
||||
]
|
||||
135
lobby/views.py
135
lobby/views.py
@@ -1,3 +1,134 @@
|
||||
from django.shortcuts import render
|
||||
import json
|
||||
import random
|
||||
|
||||
# Create your views here.
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from fupogfakta.models import GameSession, Player
|
||||
|
||||
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
SESSION_CODE_LENGTH = 6
|
||||
MAX_CODE_GENERATION_ATTEMPTS = 20
|
||||
JOINABLE_STATUSES = {
|
||||
GameSession.Status.LOBBY,
|
||||
GameSession.Status.LIE,
|
||||
GameSession.Status.GUESS,
|
||||
GameSession.Status.REVEAL,
|
||||
}
|
||||
|
||||
|
||||
def _json_body(request: HttpRequest) -> dict:
|
||||
if not request.body:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def _generate_session_code() -> str:
|
||||
return "".join(random.choices(SESSION_CODE_ALPHABET, k=SESSION_CODE_LENGTH))
|
||||
|
||||
|
||||
def _create_unique_session_code() -> str:
|
||||
for _ in range(MAX_CODE_GENERATION_ATTEMPTS):
|
||||
code = _generate_session_code()
|
||||
if not GameSession.objects.filter(code=code).exists():
|
||||
return code
|
||||
|
||||
raise RuntimeError("Could not generate unique session code")
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def create_session(request: HttpRequest) -> JsonResponse:
|
||||
code = _create_unique_session_code()
|
||||
session = GameSession.objects.create(host=request.user, code=code)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": session.status,
|
||||
"host_id": session.host_id,
|
||||
"current_round": session.current_round,
|
||||
}
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
def join_session(request: HttpRequest) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
|
||||
code = str(payload.get("code", "")).strip().upper()
|
||||
nickname = str(payload.get("nickname", "")).strip()
|
||||
|
||||
if not code:
|
||||
return JsonResponse({"error": "Session code is required"}, status=400)
|
||||
|
||||
if len(nickname) < 2 or len(nickname) > 40:
|
||||
return JsonResponse({"error": "Nickname must be between 2 and 40 characters"}, status=400)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.status not in JOINABLE_STATUSES:
|
||||
return JsonResponse({"error": "Session is not joinable"}, status=400)
|
||||
|
||||
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
|
||||
return JsonResponse({"error": "Nickname already taken"}, status=409)
|
||||
|
||||
player = Player.objects.create(session=session, nickname=nickname)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"player": {
|
||||
"id": player.id,
|
||||
"nickname": player.nickname,
|
||||
"score": player.score,
|
||||
},
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": session.status,
|
||||
},
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@require_GET
|
||||
def session_detail(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)
|
||||
|
||||
players = list(
|
||||
session.players.order_by("nickname").values(
|
||||
"id",
|
||||
"nickname",
|
||||
"score",
|
||||
"is_connected",
|
||||
)
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": session.status,
|
||||
"host_id": session.host_id,
|
||||
"current_round": session.current_round,
|
||||
"players_count": len(players),
|
||||
},
|
||||
"players": players,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from django.contrib import admin
|
||||
from django.http import JsonResponse
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
|
||||
|
||||
def health(_request):
|
||||
return JsonResponse({'ok': True, 'service': 'weirsoe-party-protocol'})
|
||||
return JsonResponse({"ok": True, "service": "weirsoe-party-protocol"})
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('healthz', health, name='healthz'),
|
||||
path("admin/", admin.site.urls),
|
||||
path("healthz", health, name="healthz"),
|
||||
path("lobby/", include("lobby.urls")),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user