diff --git a/TODO.md b/TODO.md index 83984a4..01a2c7e 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/lobby/tests.py b/lobby/tests.py index 7ce503c..5dc6733 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -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"]) diff --git a/lobby/urls.py b/lobby/urls.py new file mode 100644 index 0000000..b63dc6e --- /dev/null +++ b/lobby/urls.py @@ -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/", views.session_detail, name="session_detail"), +] diff --git a/lobby/views.py b/lobby/views.py index 91ea44a..127417a 100644 --- a/lobby/views.py +++ b/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, + } + ) diff --git a/partyhub/urls.py b/partyhub/urls.py index 4a4b4d3..dbc31a6 100644 --- a/partyhub/urls.py +++ b/partyhub/urls.py @@ -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")), ]