From 6f0e59f08a9f7e4306e461a3a839562171418a3e Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Fri, 27 Feb 2026 13:11:08 +0100 Subject: [PATCH 1/4] docs: define phase 0 anti-cheat baseline rules --- TODO.md | 2 +- docs/F0_ANTI_CHEAT_RULES.md | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 docs/F0_ANTI_CHEAT_RULES.md diff --git a/TODO.md b/TODO.md index 9aeddc8..968c3fe 100644 --- a/TODO.md +++ b/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`) diff --git a/docs/F0_ANTI_CHEAT_RULES.md b/docs/F0_ANTI_CHEAT_RULES.md new file mode 100644 index 0000000..86754cb --- /dev/null +++ b/docs/F0_ANTI_CHEAT_RULES.md @@ -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. -- 2.39.5 From ef07fa7157f93740a1441a0d465d0f91ba5db85d Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Fri, 27 Feb 2026 13:22:12 +0100 Subject: [PATCH 2/4] feat(lobby): add create/join/detail session endpoints --- lobby/urls.py | 11 ++++ lobby/views.py | 135 ++++++++++++++++++++++++++++++++++++++++++++++- partyhub/urls.py | 9 ++-- 3 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 lobby/urls.py 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")), ] -- 2.39.5 From 661226a8f58460dd5d1f94bdc5d3900ae65e289c Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Fri, 27 Feb 2026 13:22:16 +0100 Subject: [PATCH 3/4] test(lobby): cover host create and player join flow --- lobby/tests.py | 82 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) 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"]) -- 2.39.5 From 240f11f2d586954051aa74966427072196fbeca6 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Fri, 27 Feb 2026 13:30:15 +0100 Subject: [PATCH 4/4] chore(coordination): mark F3 lobby task ready for merge --- coordination/assignments.json | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/coordination/assignments.json b/coordination/assignments.json index 02d6add..1128ae6 100644 --- a/coordination/assignments.json +++ b/coordination/assignments.json @@ -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": [] } -- 2.39.5