merge: F3 lobby create/join

This commit is contained in:
2026-02-27 13:51:27 +01:00
5 changed files with 231 additions and 8 deletions

View File

@@ -53,7 +53,7 @@ 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
- [x] Lobby: host opretter session, spillere joiner via kode
- [ ] Runde starter med kategori
- [ ] Spørgsmål vises -> alle skriver løgn inden X sek
- [ ] System blander korrekt svar + løgne

View File

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

View File

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

View File

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