diff --git a/Dockerfile b/Dockerfile
index c822b15..2923f82 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,6 +2,10 @@ FROM python:3.11-slim
WORKDIR /app
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends graphviz \
+ && rm -rf /var/lib/apt/lists/*
+
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
diff --git a/Fools_Arena/routing.py b/Fools_Arena/routing.py
index 70c47e5..d47d70a 100644
--- a/Fools_Arena/routing.py
+++ b/Fools_Arena/routing.py
@@ -1,9 +1,7 @@
from channels.routing import URLRouter
-from channels.auth import AuthMiddlewareStack
from chat.routing import websocket_urlpatterns as chat_routes
from game.routing import websocket_urlpatterns as game_routes
-websocket_application = AuthMiddlewareStack(
- URLRouter(chat_routes + game_routes)
-)
+# AuthMiddlewareStack is applied once in asgi.py (do not wrap here too).
+websocket_application = URLRouter(chat_routes + game_routes)
diff --git a/Fools_Arena/settings.py b/Fools_Arena/settings.py
index fcf1d39..d0c61ae 100644
--- a/Fools_Arena/settings.py
+++ b/Fools_Arena/settings.py
@@ -40,6 +40,7 @@
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
+ "django_extensions",
'rest_framework',
'accounts',
'chat',
@@ -149,3 +150,12 @@
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+
+REST_FRAMEWORK = {
+ "DEFAULT_AUTHENTICATION_CLASSES": [
+ "rest_framework.authentication.SessionAuthentication",
+ ],
+ "DEFAULT_PERMISSION_CLASSES": [
+ "rest_framework.permissions.IsAuthenticated",
+ ],
+}
diff --git a/Fools_Arena/test_settings.py b/Fools_Arena/test_settings.py
new file mode 100644
index 0000000..4bf6ca2
--- /dev/null
+++ b/Fools_Arena/test_settings.py
@@ -0,0 +1,13 @@
+"""Django settings for automated tests (no Redis dependency).
+
+Extends production settings but swaps the Channels layer for an in-memory
+backend so ``pytest`` does not require a running Redis instance.
+"""
+
+from Fools_Arena.settings import * # noqa: F401,F403 pylint:disable=wildcard-import,unused-wildcard-import
+
+CHANNEL_LAYERS = {
+ "default": {
+ "BACKEND": "channels.layers.InMemoryChannelLayer",
+ },
+}
diff --git a/Fools_Arena/urls.py b/Fools_Arena/urls.py
index f5345e3..d76edbf 100644
--- a/Fools_Arena/urls.py
+++ b/Fools_Arena/urls.py
@@ -27,10 +27,11 @@
# UI
path("accounts/", include("accounts.urls")),
path("chat/", include("chat.urls")),
+ path("game/", include("game.urls")),
# API
path("api/accounts/", include("accounts.api_urls")),
path("api/chat/", include("chat.api_urls")),
-
+ path("api/game/", include("game.api_urls")),
]
# Add static files
diff --git a/accounts/templates/base.html b/accounts/templates/base.html
index d40fa40..7f50252 100644
--- a/accounts/templates/base.html
+++ b/accounts/templates/base.html
@@ -2,15 +2,18 @@
+
{% block title %}Fools Arena{% endblock %}
+ {% block extra_head %}{% endblock %}
-
+
@@ -22,7 +25,7 @@ Fools Arena
diff --git a/chat/consumers.py b/chat/consumers.py
index e057560..132a363 100644
--- a/chat/consumers.py
+++ b/chat/consumers.py
@@ -3,6 +3,7 @@
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
from django.contrib.auth import get_user_model
+from django.contrib.auth.models import AnonymousUser
from .models import Chat, ChatParticipant, Message
from .services import assert_can_send_message
@@ -16,6 +17,10 @@ async def connect(self):
self.user = self.scope["user"]
self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"]
+ if isinstance(self.user, AnonymousUser):
+ await self.close(code=4401)
+ return
+
# Check if user has access to the chat
if not await self.user_can_access_chat():
await self.close(code=403) # Forbidden
diff --git a/chat/services.py b/chat/services.py
index 1826f21..31115e4 100644
--- a/chat/services.py
+++ b/chat/services.py
@@ -4,8 +4,15 @@
and validation before messages are stored or broadcast.
"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
from accounts.models import Block
+if TYPE_CHECKING:
+ from game.models import Lobby
+
def is_direct_message_blocked(sender, recipient):
"""Return True if ``recipient`` has blocked ``sender`` (sender cannot DM).
@@ -22,6 +29,51 @@ def is_direct_message_blocked(sender, recipient):
return Block.objects.filter(blocker=recipient, blocked=sender).exists()
+def get_lobby_chat(lobby: Lobby):
+ """Return the lobby-linked :class:`~chat.models.Chat`, syncing active players.
+
+ Creates ``is_lobby=True`` chat on first use and ensures every non-left
+ :class:`~game.models.LobbyPlayer` is a :class:`~chat.models.ChatParticipant`.
+
+ Args:
+ lobby: :class:`~game.models.Lobby` instance.
+
+ Returns:
+ Chat: The canonical lobby chat for this lobby.
+ """
+ from game.models import LobbyPlayer
+
+ from .models import Chat
+
+ chat = (
+ Chat.objects.filter(lobby=lobby, is_lobby=True).order_by("created_at").first()
+ )
+ if chat is None:
+ chat = Chat.objects.create(
+ lobby=lobby,
+ is_lobby=True,
+ is_group=True,
+ name=(lobby.name or "Lobby chat")[:100],
+ )
+ for lp in LobbyPlayer.objects.filter(lobby=lobby).exclude(status="left"):
+ chat.add_participant(lp.user)
+ return chat
+
+
+def remove_user_from_lobby_chat(lobby: Lobby, user):
+ """Drop ``user`` from the lobby chat when they leave the lobby table.
+
+ Args:
+ lobby: Lobby the user left.
+ user: Participant to remove from the linked chat (if any).
+ """
+ from .models import Chat
+
+ chat = Chat.objects.filter(lobby=lobby, is_lobby=True).first()
+ if chat:
+ chat.remove_participant(user)
+
+
def assert_can_send_message(chat, sender):
"""Raise ``PermissionError`` if ``sender`` may not post in ``chat``.
diff --git a/conftest.py b/conftest.py
index 1a1b3b4..4879fdf 100644
--- a/conftest.py
+++ b/conftest.py
@@ -22,7 +22,7 @@ def test_basic_game_has_trump(basic_game, basic_cards):
import os
import pytest
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fools_Arena.settings")
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fools_Arena.test_settings")
import django
diff --git a/docker-compose.yml b/docker-compose.yml
index ed52fd0..43329aa 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -19,6 +19,8 @@ services:
db:
image: postgres:17
restart: always
+ ports:
+ - "5433:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
diff --git a/game/api_urls.py b/game/api_urls.py
new file mode 100644
index 0000000..653221a
--- /dev/null
+++ b/game/api_urls.py
@@ -0,0 +1,25 @@
+"""URL routes for the JSON API under ``/api/game/``."""
+
+from django.urls import path
+
+from game import api_views
+
+urlpatterns = [
+ path("lobbies/", api_views.LobbyListCreateAPI.as_view(), name="api-lobby-list"),
+ path("lobbies//", api_views.LobbyDetailAPI.as_view(), name="api-lobby-detail"),
+ path("lobbies//join/", api_views.LobbyJoinAPI.as_view(), name="api-lobby-join"),
+ path("lobbies//leave/", api_views.LobbyLeaveAPI.as_view(), name="api-lobby-leave"),
+ path("lobbies//ready/", api_views.LobbyReadyAPI.as_view(), name="api-lobby-ready"),
+ path("lobbies//start/", api_views.LobbyStartAPI.as_view(), name="api-lobby-start"),
+ path(
+ "lobbies//messages/",
+ api_views.LobbyMessagesAPI.as_view(),
+ name="api-lobby-messages",
+ ),
+ path("games//", api_views.GameStateAPI.as_view(), name="api-game-state"),
+ path("games//attack/", api_views.GameAttackAPI.as_view(), name="api-game-attack"),
+ path("games//seal/", api_views.GameSealAPI.as_view(), name="api-game-seal"),
+ path("games//defend/", api_views.GameDefendAPI.as_view(), name="api-game-defend"),
+ path("games//take/", api_views.GameTakeAPI.as_view(), name="api-game-take"),
+ path("games//bito/", api_views.GameBitoAPI.as_view(), name="api-game-bito"),
+]
diff --git a/game/api_views.py b/game/api_views.py
new file mode 100644
index 0000000..e9af7c2
--- /dev/null
+++ b/game/api_views.py
@@ -0,0 +1,346 @@
+"""REST API for lobbies, gameplay, and lobby chat.
+
+Gameplay delegates to :mod:`game.services`. Lobby-scoped lines use the shared
+:class:`chat.models.Chat` (``is_lobby=True``, FK to :class:`game.models.Lobby`)
+and :class:`chat.models.Message`; HTTP echoes are also pushed on the lobby
+WebSocket group for the game UI.
+"""
+
+from django.db.models import Count, Q
+from django.shortcuts import get_object_or_404
+from rest_framework import permissions, status
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from chat.models import Message
+from chat.services import assert_can_send_message, get_lobby_chat
+from game.models import Game, Lobby, LobbyPlayer
+from game.realtime import broadcast_lobby
+from game.serializers import (
+ AttackSerializer,
+ DefendSerializer,
+ LobbyCreateSerializer,
+ LobbyJoinSerializer,
+ LobbyMessageSerializer,
+ ReadySerializer,
+)
+from game.services import (
+ GameError,
+ bito,
+ create_lobby,
+ defend,
+ join_lobby,
+ leave_lobby,
+ play_attack,
+ seal_attack,
+ serialize_game,
+ serialize_lobby,
+ set_ready,
+ start_game,
+ take_table,
+)
+
+
+def _err(e: GameError):
+ """Map :class:`~game.services.GameError` to a DRF 400 response.
+
+ Args:
+ e: Domain error from the service layer.
+
+ Returns:
+ ``Response`` with ``detail`` and ``code`` keys.
+ """
+ return Response(
+ {"detail": e.message, "code": e.code},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+class LobbyListCreateAPI(APIView):
+ """GET public waiting lobbies; POST creates a lobby for the current user."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get(self, request):
+ qs = (
+ Lobby.objects.filter(status="waiting", is_private=False)
+ .annotate(
+ active_n=Count("players", filter=~Q(players__status="left")),
+ )
+ .filter(active_n__gt=0)
+ .select_related("settings", "owner")
+ .order_by("-created_at")
+ )
+ return Response([serialize_lobby(x) for x in qs])
+
+ def post(self, request):
+ ser = LobbyCreateSerializer(data=request.data)
+ ser.is_valid(raise_exception=True)
+ d = ser.validated_data
+ try:
+ lobby = create_lobby(
+ request.user,
+ d["name"],
+ is_private=d["is_private"],
+ password=d.get("password") or None,
+ max_players=d["max_players"],
+ card_count=d["card_count"],
+ is_transferable=d["is_transferable"],
+ neighbor_throw_only=d["neighbor_throw_only"],
+ allow_jokers=d["allow_jokers"],
+ turn_time_limit=d.get("turn_time_limit"),
+ )
+ except GameError as e:
+ return _err(e)
+ return Response(serialize_lobby(lobby), status=status.HTTP_201_CREATED)
+
+
+class LobbyDetailAPI(APIView):
+ """Retrieve a single lobby; private lobbies require membership (or ownership)."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get(self, request, lobby_id):
+ lobby = get_object_or_404(Lobby, id=lobby_id)
+ if lobby.is_private:
+ if not lobby.players.filter(user=request.user).exclude(status="left").exists():
+ if lobby.owner_id != request.user.id:
+ return Response(
+ {"detail": "Not a member of this private lobby"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+ return Response(serialize_lobby(lobby))
+
+
+class LobbyJoinAPI(APIView):
+ """Join (or idempotently re-enter) a lobby, optionally supplying a password."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def post(self, request, lobby_id):
+ lobby = get_object_or_404(Lobby, id=lobby_id)
+ if LobbyPlayer.objects.filter(lobby=lobby, user=request.user).exclude(
+ status="left"
+ ).exists():
+ return Response(serialize_lobby(lobby))
+ ser = LobbyJoinSerializer(data=request.data)
+ ser.is_valid(raise_exception=True)
+ try:
+ join_lobby(lobby, request.user, ser.validated_data.get("password"))
+ except GameError as e:
+ return _err(e)
+ return Response(serialize_lobby(lobby))
+
+
+class LobbyLeaveAPI(APIView):
+ """Mark the caller as left for this lobby."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def post(self, request, lobby_id):
+ lobby = get_object_or_404(Lobby, id=lobby_id)
+ try:
+ info = leave_lobby(lobby, request.user)
+ except GameError as e:
+ return _err(e)
+ return Response(
+ {
+ "detail": "left",
+ "lobby_closed": info["lobby_closed"],
+ "new_owner_id": info.get("new_owner_id"),
+ }
+ )
+
+
+class LobbyReadyAPI(APIView):
+ """Toggle the caller's waiting/ready flag."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def post(self, request, lobby_id):
+ lobby = get_object_or_404(Lobby, id=lobby_id)
+ ser = ReadySerializer(data=request.data)
+ ser.is_valid(raise_exception=True)
+ try:
+ set_ready(lobby, request.user, ser.validated_data["ready"])
+ except GameError as e:
+ return _err(e)
+ return Response(serialize_lobby(lobby))
+
+
+class LobbyStartAPI(APIView):
+ """Owner-only endpoint to deal the first hand and open the table."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def post(self, request, lobby_id):
+ lobby = get_object_or_404(Lobby, id=lobby_id)
+ try:
+ game = start_game(lobby, request.user)
+ except GameError as e:
+ return _err(e)
+ return Response(serialize_game(game, request.user), status=status.HTTP_201_CREATED)
+
+
+class GameStateAPI(APIView):
+ """Return masked game JSON for the authenticated participant."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get(self, request, game_id):
+ game = get_object_or_404(Game, id=game_id)
+ if not game.players.filter(user=request.user).exists():
+ return Response(
+ {"detail": "Not a player in this game"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+ return Response(serialize_game(game, request.user))
+
+
+class GameAttackAPI(APIView):
+ """Attack or throw-in: play one or more cards from the actor's hand."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def post(self, request, game_id):
+ game = get_object_or_404(Game, id=game_id)
+ if not game.players.filter(user=request.user).exists():
+ return Response(status=status.HTTP_403_FORBIDDEN)
+ ser = AttackSerializer(data=request.data)
+ ser.is_valid(raise_exception=True)
+ try:
+ play_attack(game, request.user, ser.validated_data["card_ids"])
+ except GameError as e:
+ return _err(e)
+ return Response(serialize_game(game, request.user))
+
+
+class GameSealAPI(APIView):
+ """Primary attacker closes the attack wave (move to defend phase)."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def post(self, request, game_id):
+ game = get_object_or_404(Game, id=game_id)
+ if not game.players.filter(user=request.user).exists():
+ return Response(status=status.HTTP_403_FORBIDDEN)
+ try:
+ seal_attack(game, request.user)
+ except GameError as e:
+ return _err(e)
+ return Response(serialize_game(game, request.user))
+
+
+class GameDefendAPI(APIView):
+ """Defender beats a single table row with a chosen hand card."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def post(self, request, game_id):
+ game = get_object_or_404(Game, id=game_id)
+ if not game.players.filter(user=request.user).exists():
+ return Response(status=status.HTTP_403_FORBIDDEN)
+ ser = DefendSerializer(data=request.data)
+ ser.is_valid(raise_exception=True)
+ d = ser.validated_data
+ try:
+ defend(game, request.user, d["table_card_id"], d["card_id"])
+ except GameError as e:
+ return _err(e)
+ return Response(serialize_game(game, request.user))
+
+
+class GameTakeAPI(APIView):
+ """Defender takes the whole table into hand and draws from stock."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def post(self, request, game_id):
+ game = get_object_or_404(Game, id=game_id)
+ if not game.players.filter(user=request.user).exists():
+ return Response(status=status.HTTP_403_FORBIDDEN)
+ try:
+ take_table(game, request.user)
+ except GameError as e:
+ return _err(e)
+ return Response(serialize_game(game, request.user))
+
+
+class GameBitoAPI(APIView):
+ """Successful round: discard defended cards and rotate attacker/defender."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def post(self, request, game_id):
+ game = get_object_or_404(Game, id=game_id)
+ if not game.players.filter(user=request.user).exists():
+ return Response(status=status.HTTP_403_FORBIDDEN)
+ try:
+ bito(game, request.user)
+ except GameError as e:
+ return _err(e)
+ return Response(serialize_game(game, request.user))
+
+
+class LobbyMessagesAPI(APIView):
+ """List recent lobby chat messages or append a new one (members only)."""
+
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get(self, request, lobby_id):
+ lobby = get_object_or_404(Lobby, id=lobby_id)
+ if not lobby.players.filter(user=request.user).exclude(status="left").exists():
+ return Response(status=status.HTTP_403_FORBIDDEN)
+ chat = get_lobby_chat(lobby)
+ msgs = list(Message.objects.filter(chat=chat).order_by("-sent_at")[:100])
+ msgs.reverse()
+ return Response(
+ [
+ {
+ "id": str(m.id),
+ "sender_id": str(m.sender_id),
+ "username": m.sender.username,
+ "content": m.content,
+ "sent_at": m.sent_at.isoformat(),
+ }
+ for m in msgs
+ ]
+ )
+
+ def post(self, request, lobby_id):
+ lobby = get_object_or_404(Lobby, id=lobby_id)
+ if not lobby.players.filter(user=request.user).exclude(status="left").exists():
+ return Response(status=status.HTTP_403_FORBIDDEN)
+ ser = LobbyMessageSerializer(data=request.data)
+ ser.is_valid(raise_exception=True)
+ chat = get_lobby_chat(lobby)
+ try:
+ assert_can_send_message(chat, request.user)
+ except PermissionError as exc:
+ return Response({"detail": str(exc)}, status=status.HTTP_403_FORBIDDEN)
+ m = Message.objects.create(
+ sender=request.user,
+ chat=chat,
+ content=ser.validated_data["content"],
+ )
+ broadcast_lobby(
+ lobby.id,
+ "lobby_chat",
+ {
+ "id": str(m.id),
+ "sender_id": str(m.sender_id),
+ "username": m.sender.username,
+ "content": m.content,
+ "sent_at": m.sent_at.isoformat(),
+ },
+ )
+ return Response(
+ {
+ "id": str(m.id),
+ "sender_id": str(m.sender_id),
+ "username": m.sender.username,
+ "content": m.content,
+ "sent_at": m.sent_at.isoformat(),
+ },
+ status=status.HTTP_201_CREATED,
+ )
diff --git a/game/card_catalog.py b/game/card_catalog.py
new file mode 100644
index 0000000..cc8ed43
--- /dev/null
+++ b/game/card_catalog.py
@@ -0,0 +1,203 @@
+"""Canonical playing-card catalog: dedupe rows and ensure a full shoe exists.
+
+Used by ``init_game_data`` and by :func:`game.services.start_game` when the DB
+is missing cards. Merges duplicate ``CardSuit`` (same name), ``CardRank`` (same
+value), and normal ``Card`` rows (same suit + rank, ``special_card`` NULL).
+"""
+
+from __future__ import annotations
+
+from typing import List, Tuple
+
+from django.db import transaction
+from django.db.models import Count
+
+from game.models import Card, CardRank, CardSuit, DiscardPile, Game, GameDeck, PlayerHand, TableCard
+
+
+def ranks_for_deck_size(size: int) -> List[Tuple[str, int]]:
+ """Return (name, value) rank rows for a 24 / 36 / 52-card shoe."""
+ face = [("Jack", 11), ("Queen", 12), ("King", 13), ("Ace", 14)]
+ if size == 52:
+ numeric = [(str(i), i) for i in range(2, 11)]
+ return numeric + face
+ if size == 36:
+ numeric = [(str(i), i) for i in range(6, 11)]
+ return numeric + face
+ if size == 24:
+ numeric = [("9", 9), ("10", 10)]
+ return numeric + face
+ raise ValueError(f"Unsupported deck size: {size}")
+
+
+CANONICAL_SUITS: List[Tuple[str, str]] = [
+ ("Hearts", "red"),
+ ("Diamonds", "red"),
+ ("Clubs", "black"),
+ ("Spades", "black"),
+]
+
+
+def _reassign_card_pk(old_id, new_id) -> None:
+ """Point all FKs from ``old_id`` to ``new_id``, then delete the old ``Card``.
+
+ Bulk ``UPDATE`` on :class:`~game.models.PlayerHand` or :class:`~game.models.GameDeck`
+ can violate uniqueness if the same player or deck already references ``new_id``
+ (two physical duplicate rows for one suit/rank were both dealt). In that case
+ drop the redundant row instead of updating it.
+ """
+ if old_id == new_id:
+ return
+ Game.objects.filter(trump_card_id=old_id).update(trump_card_id=new_id)
+
+ for gd in GameDeck.objects.filter(card_id=old_id).order_by("id"):
+ if GameDeck.objects.filter(game_id=gd.game_id, card_id=new_id).exclude(pk=gd.pk).exists():
+ gd.delete()
+ else:
+ GameDeck.objects.filter(pk=gd.pk).update(card_id=new_id)
+
+ for ph in PlayerHand.objects.filter(card_id=old_id).order_by("id"):
+ if PlayerHand.objects.filter(game_id=ph.game_id, player_id=ph.player_id, card_id=new_id).exclude(
+ pk=ph.pk
+ ).exists():
+ ph.delete()
+ else:
+ PlayerHand.objects.filter(pk=ph.pk).update(card_id=new_id)
+
+ for tc in TableCard.objects.filter(attack_card_id=old_id).order_by("id"):
+ if TableCard.objects.filter(game_id=tc.game_id, attack_card_id=new_id).exclude(pk=tc.pk).exists():
+ tc.delete()
+ else:
+ TableCard.objects.filter(pk=tc.pk).update(attack_card_id=new_id)
+
+ for tc in TableCard.objects.filter(defense_card_id=old_id).order_by("id"):
+ if TableCard.objects.filter(game_id=tc.game_id, defense_card_id=new_id).exclude(pk=tc.pk).exists():
+ tc.delete()
+ else:
+ TableCard.objects.filter(pk=tc.pk).update(defense_card_id=new_id)
+
+ for dp in DiscardPile.objects.filter(card_id=old_id).order_by("id"):
+ if DiscardPile.objects.filter(game_id=dp.game_id, card_id=new_id).exclude(pk=dp.pk).exists():
+ dp.delete()
+ else:
+ DiscardPile.objects.filter(pk=dp.pk).update(card_id=new_id)
+
+ Card.objects.filter(pk=old_id).delete()
+
+
+def dedupe_card_suits() -> int:
+ """Merge duplicate suits with the same ``name``; keep lowest ``id``. Returns removed count."""
+ removed = 0
+ names = CardSuit.objects.values_list("name", flat=True).distinct()
+ for name in names:
+ qs = list(CardSuit.objects.filter(name=name).order_by("id"))
+ if len(qs) <= 1:
+ continue
+ keeper = qs[0]
+ for dup in qs[1:]:
+ Card.objects.filter(suit_id=dup.pk).update(suit_id=keeper.pk)
+ dup.delete()
+ removed += 1
+ return removed
+
+
+def dedupe_card_ranks() -> int:
+ """Merge duplicate ranks with the same ``value``; keep lowest ``id``. Returns removed count."""
+ removed = 0
+ values = CardRank.objects.values_list("value", flat=True).distinct()
+ for value in values:
+ qs = list(CardRank.objects.filter(value=value).order_by("id"))
+ if len(qs) <= 1:
+ continue
+ keeper = qs[0]
+ for dup in qs[1:]:
+ Card.objects.filter(rank_id=dup.pk).update(rank_id=keeper.pk)
+ dup.delete()
+ removed += 1
+ return removed
+
+
+def dedupe_normal_playing_cards() -> int:
+ """Merge duplicate normal cards (same suit + rank); keep smallest ``id``. Returns removed count."""
+ removed = 0
+ keys = (
+ Card.objects.filter(special_card__isnull=True)
+ .values("suit_id", "rank_id")
+ .annotate(c=Count("id"))
+ .filter(c__gt=1)
+ )
+ for row in keys:
+ qs = list(
+ Card.objects.filter(
+ suit_id=row["suit_id"],
+ rank_id=row["rank_id"],
+ special_card__isnull=True,
+ ).order_by("id")
+ )
+ keeper_id = qs[0].pk
+ for dup in qs[1:]:
+ _reassign_card_pk(dup.pk, keeper_id)
+ removed += 1
+ return removed
+
+
+def _ensure_suits_and_ranks(deck_size: int) -> tuple[list[CardSuit], list[CardRank]]:
+ suits: list[CardSuit] = []
+ for name, color in CANONICAL_SUITS:
+ suit_obj, _ = CardSuit.objects.get_or_create(name=name, defaults={"color": color})
+ if suit_obj.color != color:
+ suit_obj.color = color
+ suit_obj.save(update_fields=["color"])
+ suits.append(suit_obj)
+
+ rank_rows = ranks_for_deck_size(deck_size)
+ ranks: list[CardRank] = []
+ for name, value in rank_rows:
+ rank_obj, _ = CardRank.objects.get_or_create(value=value, defaults={"name": name})
+ if rank_obj.name != name:
+ rank_obj.name = name
+ rank_obj.save(update_fields=["name"])
+ ranks.append(rank_obj)
+ return suits, ranks
+
+
+def _create_missing_cards(suits: list[CardSuit], ranks: list[CardRank]) -> tuple[int, int]:
+ created = 0
+ skipped = 0
+ for suit in suits:
+ for rank in ranks:
+ exists = Card.objects.filter(suit=suit, rank=rank, special_card__isnull=True).exists()
+ if exists:
+ skipped += 1
+ continue
+ Card.objects.create(suit=suit, rank=rank)
+ created += 1
+ return created, skipped
+
+
+@transaction.atomic
+def ensure_playing_cards_for_deck_size(deck_size: int) -> dict:
+ """Dedupe catalog rows, then create any missing standard cards for ``deck_size``.
+
+ Args:
+ deck_size: One of ``24``, ``36``, ``52``.
+
+ Returns:
+ Stats dict with keys ``suits_removed``, ``ranks_removed``, ``cards_merged``,
+ ``cards_created``, ``cards_skipped``.
+ """
+ if deck_size not in (24, 36, 52):
+ raise ValueError(f"Unsupported deck_size: {deck_size}")
+
+ sr = dedupe_card_suits()
+ rr = dedupe_card_ranks()
+ suits, ranks = _ensure_suits_and_ranks(deck_size)
+ cc, sk = _create_missing_cards(suits, ranks)
+ cm = dedupe_normal_playing_cards()
+ return {
+ "suits_removed": sr,
+ "ranks_removed": rr,
+ "cards_merged": cm,
+ "cards_created": cc,
+ "cards_skipped": sk,
+ }
diff --git a/game/consumers.py b/game/consumers.py
new file mode 100644
index 0000000..7096783
--- /dev/null
+++ b/game/consumers.py
@@ -0,0 +1,92 @@
+"""Async Channels consumers for lobby and per-game WebSocket channels.
+
+Clients connect with session authentication (see ``AuthMiddlewareStack``). Each
+connection joins a single group and receives ``game.event`` fan-out messages
+originating from :mod:`game.realtime`.
+"""
+
+from channels.db import database_sync_to_async
+from channels.generic.websocket import AsyncJsonWebsocketConsumer
+from django.contrib.auth.models import AnonymousUser
+
+from game.models import Game, LobbyPlayer
+
+
+@database_sync_to_async
+def user_in_lobby(user_id, lobby_id):
+ """Return whether the user has a non-left membership in the lobby.
+
+ Args:
+ user_id: Primary key of :class:`~django.contrib.auth.models.User`.
+ lobby_id: Lobby UUID string or UUID.
+
+ Returns:
+ True if an active :class:`~game.models.LobbyPlayer` row exists.
+ """
+ return LobbyPlayer.objects.filter(
+ lobby_id=lobby_id, user_id=user_id
+ ).exclude(status="left").exists()
+
+
+@database_sync_to_async
+def user_in_game(user_id, game_id):
+ """Return whether the user participates in the given game.
+
+ Args:
+ user_id: User primary key.
+ game_id: Game UUID.
+
+ Returns:
+ True if a :class:`~game.models.GamePlayer` row links the pair.
+ """
+ return Game.objects.filter(id=game_id, players__user_id=user_id).exists()
+
+
+class LobbyConsumer(AsyncJsonWebsocketConsumer):
+ """Stream lobby-scoped events (chat, membership, game start) to members."""
+
+ async def connect(self):
+ """Accept the socket after auth + membership checks."""
+ self.lobby_id = str(self.scope["url_route"]["kwargs"]["lobby_id"])
+ user = self.scope["user"]
+ if isinstance(user, AnonymousUser):
+ await self.close(code=4401)
+ return
+ if not await user_in_lobby(user.id, self.lobby_id):
+ await self.close(code=4403)
+ return
+ await self.channel_layer.group_add(f"lobby_{self.lobby_id}", self.channel_name)
+ await self.accept()
+
+ async def disconnect(self, code):
+ """Leave the lobby group when the socket closes."""
+ await self.channel_layer.group_discard(f"lobby_{self.lobby_id}", self.channel_name)
+
+ async def game_event(self, event):
+ """Forward channel-layer messages to the browser as JSON."""
+ await self.send_json(event["message"])
+
+
+class GameConsumer(AsyncJsonWebsocketConsumer):
+ """Stream table updates to everyone seated in the same ``Game``."""
+
+ async def connect(self):
+ """Accept after verifying the user is one of the game's players."""
+ self.game_id = str(self.scope["url_route"]["kwargs"]["game_id"])
+ user = self.scope["user"]
+ if isinstance(user, AnonymousUser):
+ await self.close(code=4401)
+ return
+ if not await user_in_game(user.id, self.game_id):
+ await self.close(code=4403)
+ return
+ await self.channel_layer.group_add(f"game_{self.game_id}", self.channel_name)
+ await self.accept()
+
+ async def disconnect(self, code):
+ """Detach from the game broadcast group."""
+ await self.channel_layer.group_discard(f"game_{self.game_id}", self.channel_name)
+
+ async def game_event(self, event):
+ """Push server events (moves, phase changes) to the client."""
+ await self.send_json(event["message"])
diff --git a/game/management/commands/init_game_data.py b/game/management/commands/init_game_data.py
index d113a4b..0599b11 100644
--- a/game/management/commands/init_game_data.py
+++ b/game/management/commands/init_game_data.py
@@ -1,190 +1,76 @@
"""
Initialize default card suits, ranks and create Card entries.
-This management command will create standard card suits and ranks and then
-create Card objects for each suit × rank combination for a chosen deck size.
+This management command delegates to :mod:`game.card_catalog`, which merges
+duplicate suits (same name), ranks (same value), and normal cards (same suit
+and rank), then ensures every suit × rank pair exists for the chosen deck size.
Usage:
python manage.py init_game_data
python manage.py init_game_data --deck-size 36
python manage.py init_game_data --reset
-The command is idempotent by default (it uses get_or_create and updates mismatched
-names/colors). Using --reset will delete existing Card, CardRank and CardSuit
-records before recreating them.
-
-Module contents:
- Command -- Django management command class implementing the behavior.
+Using --reset deletes all Card, CardRank and CardSuit rows before rebuilding.
"""
-from typing import List, Tuple
-
from django.core.management.base import BaseCommand
from django.db import transaction
-from game.models import CardSuit, CardRank, Card
+from game.card_catalog import ensure_playing_cards_for_deck_size
+from game.models import Card, CardRank, CardSuit
class Command(BaseCommand):
- """
- Django management command to initialize card suits, ranks and cards.
-
- The command supports 24-, 36- and 52-card decks and an optional reset flag
- which deletes existing Card, CardRank and CardSuit records before creating
- new ones.
+ """Build or repair the standard playing-card catalog (24 / 36 / 52 cards)."""
- Attributes:
- help (str): Short description displayed by `manage.py help`.
- """
-
- # Standard four suits with their display colors.
- suits = [
- ("Hearts", "red"),
- ("Diamonds", "red"),
- ("Clubs", "black"),
- ("Spades", "black"),
- ]
-
- help = "Initialize default card suits, ranks and create Card entries. Default deck: 36 (Durak)."
+ help = (
+ "Initialize card suits, ranks, and cards; merge duplicates; "
+ "default deck size is 52 (use 36 for classic Durak)."
+ )
def add_arguments(self, parser):
- """
- Add command-line arguments for the management command.
-
- Args:
- parser (argparse.ArgumentParser): The argument parser instance.
-
- Recognized flags:
- --deck-size {24,36,52}: Which deck to create (default 52).
- --reset: If present, deletes existing Card/Rank/Suit rows before creating.
- """
parser.add_argument(
"--deck-size",
type=int,
choices=[24, 36, 52],
default=52,
- help="Which deck to create cards for: 24 (9-A), 36 (6-A), 52 (2-A). Default: 52.",
+ help="Deck to materialize: 24 (9-A), 36 (6-A), 52 (2-A). Default: 52.",
)
parser.add_argument(
"--reset",
action="store_true",
- help="Delete existing Card, CardRank and CardSuit records and recreate from scratch.",
+ help="Delete all Card, CardRank and CardSuit rows before rebuilding.",
)
- def ranks_for_deck(self, size: int) -> List[Tuple[str, int]]:
- """
- Return a list of (name, value) tuples representing card ranks for the given deck size.
-
- The returned list orders ranks from lowest to highest numeric value.
-
- Args:
- size (int): Deck size. Supported values: 24, 36, 52.
-
- Returns:
- List[Tuple[str, int]]: List of (display_name, numeric_value) for ranks.
-
- Raises:
- ValueError: If an unsupported deck size is supplied.
- """
- face = [("Jack", 11), ("Queen", 12), ("King", 13), ("Ace", 14)]
- if size == 52:
- # 2..10 plus face cards
- numeric = [(str(i), i) for i in range(2, 11)]
- return numeric + face
- if size == 36:
- # 6..10 plus face cards (typical Durak deck)
- numeric = [(str(i), i) for i in range(6, 11)]
- return numeric + face
- if size == 24:
- # 9..10 plus face cards (short deck)
- numeric = [("9", 9), ("10", 10)]
- return numeric + face
- raise ValueError("Unsupported deck size")
-
- def create_suits(self):
- # Create or update suits
- created_suits = []
- for name, color in self.suits:
- suit_obj, created = CardSuit.objects.get_or_create(name=name, defaults={"color": color})
- # If suit exists but has a different color, update it to our canonical color.
- if not created and getattr(suit_obj, "color", None) != color:
- suit_obj.color = color
- suit_obj.save(update_fields=["color"])
- created_suits.append(suit_obj)
- self.stdout.write(f"{'Created' if created else 'Found'} suit: {suit_obj.name} ({suit_obj.color})")
- return created_suits
-
- def create_ranks(self, ranks: List[Tuple[str, int]]) -> List[Tuple[str, int]]:
- # Create or update ranks
- created_ranks = []
- for name, value in ranks:
- rank_obj, created = CardRank.objects.get_or_create(value=value, defaults={"name": name})
- # Normalize the printable name if it differs from our desired name.
- if not created and getattr(rank_obj, "name", None) != name:
- rank_obj.name = name
- rank_obj.save(update_fields=["name"])
- created_ranks.append(rank_obj)
- self.stdout.write(f"{'Created' if created else 'Found'} rank: {rank_obj.name} (value={rank_obj.value})")
- return created_ranks
-
- def create_cards(self, ranks: List[Tuple[str, int]]):
- # Create cards for every suit × rank (skip cards that already exist)
- created_cards = 0
- skipped_cards = 0
- created_suits = self.create_suits()
- created_ranks = self.create_ranks(ranks)
- for suit in created_suits:
- for rank in created_ranks:
- # If a Card with the suit & rank already exists (and is not a special card),
- # skip creating a duplicate.
- card_qs = Card.objects.filter(suit=suit, rank=rank, special_card__isnull=True)
- if card_qs.exists():
- skipped_cards += 1
- continue
- Card.objects.create(suit=suit, rank=rank)
- created_cards += 1
-
- return created_cards, skipped_cards
-
def handle(self, *args, **options):
- """
- Main entry point for the management command.
-
- This method creates suits and ranks (using get_or_create so it is safe to
- run repeatedly), then creates Card objects for each combination of suit
- and rank. If --reset is passed, existing Card, CardRank and CardSuit
- records will be deleted first.
-
- Args:
- *args: Positional arguments (unused).
- **options: Command options dictionary with keys:
- deck_size (int): Deck size to create (24, 36, 52).
- reset (bool): Whether to delete existing entries first.
-
- Raises:
- ValueError: If an unsupported deck size is provided (shouldn't happen
- because argparse restricts choices).
- """
deck_size = options["deck_size"]
do_reset = options["reset"]
- ranks = self.ranks_for_deck(deck_size)
-
with transaction.atomic():
if do_reset:
- self.stdout.write("Reset requested — deleting existing Cards, CardRank and CardSuit entries...")
- # Delete Cards first because of foreign key references to ranks & suits
+ self.stdout.write(
+ "Reset requested — deleting existing Cards, CardRank and CardSuit entries..."
+ )
Card.objects.all().delete()
CardRank.objects.all().delete()
CardSuit.objects.all().delete()
self.stdout.write("Existing card data deleted.")
- created_cards, skipped_cards = self.create_cards(ranks)
+ stats = ensure_playing_cards_for_deck_size(deck_size)
- # Summary output
- self.stdout.write(self.style.SUCCESS(
- f"Deck initialization finished for deck_size={deck_size}."
- ))
- self.stdout.write(f"Cards created: {created_cards}. Cards already present: {skipped_cards}.")
- self.stdout.write(
- f"Total suits: {CardSuit.objects.count()}; ranks: {CardRank.objects.count()}; cards: {Card.objects.count()}")
+ self.stdout.write(
+ f"Dedupe: removed {stats['suits_removed']} duplicate suit(s), "
+ f"{stats['ranks_removed']} duplicate rank(s), "
+ f"merged {stats['cards_merged']} duplicate card row(s)."
+ )
+ self.stdout.write(
+ f"Cards created: {stats['cards_created']}; already present: {stats['cards_skipped']}."
+ )
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"Deck ready for deck_size={deck_size}. "
+ f"Total suits: {CardSuit.objects.count()}; "
+ f"ranks: {CardRank.objects.count()}; "
+ f"cards: {Card.objects.count()}."
+ )
+ )
diff --git a/game/migrations/0004_game_runtime_state.py b/game/migrations/0004_game_runtime_state.py
new file mode 100644
index 0000000..bfa878f
--- /dev/null
+++ b/game/migrations/0004_game_runtime_state.py
@@ -0,0 +1,18 @@
+# Generated manually for Durak runtime state (phase, attacker/defender).
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("game", "0003_turn_move"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="game",
+ name="runtime_state",
+ field=models.JSONField(blank=True, default=dict),
+ ),
+ ]
diff --git a/game/models.py b/game/models.py
index 2133fdd..4698180 100644
--- a/game/models.py
+++ b/game/models.py
@@ -251,6 +251,8 @@ class Game(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
lobby = models.ForeignKey(Lobby, on_delete=models.CASCADE)
trump_card = models.ForeignKey('Card', on_delete=models.PROTECT, related_name='as_trump')
+ # attacker_id, defender_id, phase: build | defend | between
+ runtime_state = models.JSONField(default=dict, blank=True)
started_at = models.DateTimeField(auto_now_add=True)
finished_at = models.DateTimeField(null=True, blank=True)
status = models.CharField(max_length=15, choices=[('in_progress', 'In Progress'), ('finished', 'Finished')])
@@ -851,6 +853,15 @@ def discard_cards(cls, game, cards):
"""
last_position = cls.objects.filter(game=game).count()
discard_entries = []
+ for i, card in enumerate(cards):
+ discard_entries.append(
+ cls.objects.create(
+ game=game,
+ card=card,
+ position=last_position + i + 1,
+ )
+ )
+ return discard_entries
class Turn(models.Model):
"""Turn tracking model for managing player turn sequence in games.
diff --git a/game/realtime.py b/game/realtime.py
new file mode 100644
index 0000000..6948a5a
--- /dev/null
+++ b/game/realtime.py
@@ -0,0 +1,59 @@
+"""Synchronous helpers that publish JSON events to Django Channels groups.
+
+Consumers subscribe to ``lobby_{uuid}`` and ``game_{uuid}``; these helpers use
+``group_send`` with type ``game.event`` so :class:`game.consumers.LobbyConsumer`
+and :class:`game.consumers.GameConsumer` can relay payloads to browsers.
+"""
+
+from asgiref.sync import async_to_sync
+from channels.layers import get_channel_layer
+
+
+def _send_group(group: str, message: dict):
+ """Deliver ``message`` to every socket in ``group``.
+
+ Args:
+ group: Channel layer group name (e.g. ``lobby_``).
+ message: JSON-serializable dict forwarded to clients.
+
+ Returns:
+ None: No-op when the channel layer is not configured.
+ """
+ layer = get_channel_layer()
+ if not layer:
+ return
+ async_to_sync(layer.group_send)(
+ group,
+ {
+ "type": "game.event",
+ "message": message,
+ },
+ )
+
+
+def broadcast_lobby(lobby_id, event: str, payload: dict | None = None):
+ """Notify every subscriber of a lobby room.
+
+ Args:
+ lobby_id: UUID of the :class:`~game.models.Lobby`.
+ event: Short event name (e.g. ``player_joined``).
+ payload: Optional extra fields merged into the outbound message.
+ """
+ _send_group(
+ f"lobby_{lobby_id}",
+ {"event": event, "payload": payload or {}},
+ )
+
+
+def broadcast_game(game_id, event: str, payload: dict | None = None):
+ """Notify every subscriber of a running table.
+
+ Args:
+ game_id: UUID of the :class:`~game.models.Game`.
+ event: Short event name (e.g. ``\"game_update\"``).
+ payload: Optional extra fields for clients.
+ """
+ _send_group(
+ f"game_{game_id}",
+ {"event": event, "payload": payload or {}},
+ )
diff --git a/game/routing.py b/game/routing.py
index 9338e54..850b845 100644
--- a/game/routing.py
+++ b/game/routing.py
@@ -1,5 +1,8 @@
from django.urls import path
-websocket_urlpatterns = [
+from game import consumers
+websocket_urlpatterns = [
+ path("ws/lobbies//", consumers.LobbyConsumer.as_asgi()),
+ path("ws/games//", consumers.GameConsumer.as_asgi()),
]
diff --git a/game/serializers.py b/game/serializers.py
new file mode 100644
index 0000000..1a2cb32
--- /dev/null
+++ b/game/serializers.py
@@ -0,0 +1,73 @@
+"""Request-body serializers for game REST endpoints.
+
+These are intentionally lightweight (no ``ModelSerializer``) because responses
+are built from :func:`game.services.serialize_lobby` /
+:func:`game.services.serialize_game`.
+"""
+
+from rest_framework import serializers
+
+
+class LobbyCreateSerializer(serializers.Serializer):
+ """Validate payload for :class:`game.api_views.LobbyListCreateAPI` POST."""
+
+ name = serializers.CharField(max_length=100)
+ is_private = serializers.BooleanField(default=False)
+ password = serializers.CharField(
+ max_length=128, required=False, allow_blank=True, write_only=True
+ )
+
+ def validate(self, attrs):
+ """Require a non-empty password when ``is_private`` is true.
+
+ Args:
+ attrs: Incoming validated fields.
+
+ Returns:
+ The same dict, possibly normalized.
+
+ Raises:
+ serializers.ValidationError: When a private lobby lacks a password.
+ """
+ if attrs.get("is_private") and not (attrs.get("password") or "").strip():
+ raise serializers.ValidationError(
+ {"password": "Password is required for a private lobby."}
+ )
+ return attrs
+ max_players = serializers.IntegerField(min_value=2, max_value=8, default=4)
+ card_count = serializers.ChoiceField(choices=[24, 36, 52], default=36)
+ is_transferable = serializers.BooleanField(default=False)
+ neighbor_throw_only = serializers.BooleanField(default=False)
+ allow_jokers = serializers.BooleanField(default=False)
+ turn_time_limit = serializers.IntegerField(required=False, allow_null=True, min_value=0)
+
+
+class LobbyJoinSerializer(serializers.Serializer):
+ """Optional password when joining a private lobby."""
+ password = serializers.CharField(
+ max_length=128, required=False, allow_blank=True, write_only=True
+ )
+
+
+class ReadySerializer(serializers.Serializer):
+ """Boolean ready flag for lobby members."""
+ ready = serializers.BooleanField()
+
+
+class AttackSerializer(serializers.Serializer):
+ """List of card UUIDs played from the actor's hand."""
+ card_ids = serializers.ListField(
+ child=serializers.UUIDField(),
+ allow_empty=False,
+ )
+
+
+class DefendSerializer(serializers.Serializer):
+ """Table row id plus defending card id."""
+ table_card_id = serializers.UUIDField()
+ card_id = serializers.UUIDField()
+
+
+class LobbyMessageSerializer(serializers.Serializer):
+ """Free-text lobby chat body."""
+ content = serializers.CharField(max_length=4000)
diff --git a/game/services.py b/game/services.py
new file mode 100644
index 0000000..0a7cac4
--- /dev/null
+++ b/game/services.py
@@ -0,0 +1,909 @@
+"""Business logic for multiplayer Durak (podkidnoy).
+
+This module owns lobby lifecycle, dealing from the shared card pool, table
+phases (build / defend / between), and persistence updates on existing Django
+models. It also triggers WebSocket broadcasts via :mod:`game.realtime`.
+
+Typical flow:
+ #. Players create or join a lobby, toggle ready, owner calls ``start_game``.
+ #. Attacker opens from ``between`` into ``build``. While the wave is on the table,
+ the defender may ``defend`` or ``take_table`` at any time; the attacker and
+ other players may still ``play_attack`` (throw-ins) in parallel—no separate
+ ``defend`` phase gate.
+ #. After the table is fully beaten, attacker ``bito`` discards and rotates roles.
+"""
+
+from __future__ import annotations
+
+import secrets
+from typing import Iterable
+from uuid import UUID
+
+from django.contrib.auth import get_user_model
+from django.contrib.auth.hashers import check_password, make_password
+from django.db import transaction
+from django.db.models import Max
+from django.utils import timezone
+
+from game.models import (
+ Card,
+ DiscardPile,
+ Game,
+ GameDeck,
+ GamePlayer,
+ Lobby,
+ LobbyPlayer,
+ LobbySettings,
+ Move,
+ PlayerHand,
+ TableCard,
+ Turn,
+)
+from chat.services import get_lobby_chat, remove_user_from_lobby_chat
+from game.card_catalog import ensure_playing_cards_for_deck_size
+from game.realtime import broadcast_game, broadcast_lobby
+
+User = get_user_model()
+
+PHASE_BUILD = "build"
+PHASE_DEFEND = "defend"
+PHASE_BETWEEN = "between"
+
+
+def rank_values_for_deck(card_count: int) -> list[int]:
+ """Return ordered rank *values* (ints) included in a 24/36/52-card shoe.
+
+ Args:
+ card_count: One of ``24``, ``36``, or ``52``.
+
+ Returns:
+ List of rank values from low to high (e.g. six..ace for 36 cards).
+
+ Raises:
+ ValueError: If ``card_count`` is not supported.
+ """
+ if card_count == 52:
+ return list(range(2, 15))
+ if card_count == 36:
+ return list(range(6, 15))
+ if card_count == 24:
+ return list(range(9, 15))
+ raise ValueError("Unsupported card_count")
+
+
+class GameError(Exception):
+ """Domain error with a stable machine-readable ``code`` for API mapping.
+
+ Attributes:
+ message: Human-readable explanation.
+ code: Short string such as ``forbidden`` or ``phase``.
+ """
+
+ def __init__(self, message: str, code: str = "invalid"):
+ self.message = message
+ self.code = code
+ super().__init__(message)
+
+
+def _rs(game: Game) -> dict:
+ return dict(game.runtime_state or {})
+
+
+def _save_rs(game: Game, **updates):
+ rs = _rs(game)
+ rs.update(updates)
+ game.runtime_state = rs
+ game.save(update_fields=["runtime_state"])
+
+
+def _refresh_card_counts(game: Game):
+ for gp in game.players.all():
+ n = PlayerHand.objects.filter(game=game, player=gp.user).count()
+ if gp.cards_remaining != n:
+ gp.cards_remaining = n
+ gp.save(update_fields=["cards_remaining"])
+
+
+def _player_circle(game: Game) -> list[GamePlayer]:
+ return list(game.players.order_by("seat_position"))
+
+
+def _neighbor_user_ids(game: Game, defender_id: UUID) -> set[UUID]:
+ players = _player_circle(game)
+ idx = next(i for i, p in enumerate(players) if p.user_id == defender_id)
+ n = len(players)
+ return {
+ players[(idx - 1) % n].user_id,
+ players[(idx + 1) % n].user_id,
+ }
+
+
+_SHOE_CARD_COUNT = {24: 24, 36: 36, 52: 52}
+
+
+def _deck_card_ids_for_shoe(settings: LobbySettings) -> list[UUID]:
+ """Return exactly one card PK per (suit, rank) for the configured shoe.
+
+ Duplicate ``Card`` rows for the same suit and rank are possible in the
+ database because ``unique_together`` treats multiple ``NULL``
+ ``special_card`` values as distinct. A physical deck must contain each
+ suit/rank combination at most once; the row with the smallest id wins
+ (stable ordering). Done in Python because ``MIN(uuid)`` is not valid SQL
+ on PostgreSQL.
+ """
+ values = rank_values_for_deck(settings.card_count)
+ qs = Card.objects.filter(special_card__isnull=True, rank__value__in=values).order_by(
+ "suit_id", "rank_id", "id"
+ )
+ seen: set[tuple[int, int]] = set()
+ out: list[UUID] = []
+ for card in qs:
+ key = (card.suit_id, card.rank_id)
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append(card.id)
+ return out
+
+
+def _pick_first_attacker(game: Game, trump_suit_id) -> UUID:
+ """Choose the opening attacker by lowest trump, then lowest seat.
+
+ Args:
+ game: Active game with hands already dealt.
+ trump_suit_id: Primary key of the trump :class:`~game.models.CardSuit`.
+
+ Returns:
+ UUID of the user who should attack first.
+ """
+ best: tuple[int, int, UUID] | None = None
+ for gp in _player_circle(game):
+ trumps = (
+ PlayerHand.objects.filter(game=game, player=gp.user, card__suit_id=trump_suit_id)
+ .select_related("card__rank")
+ )
+ for ph in trumps:
+ val = ph.card.rank.value
+ key = (val, gp.seat_position, gp.user_id)
+ if best is None or key < best:
+ best = key
+ if best:
+ return best[2]
+ return _player_circle(game)[0].user_id
+
+
+def _draw_for_player(game: Game, user: User, target_hand: int = 6):
+ while True:
+ gp = GamePlayer.objects.get(game=game, user=user)
+ if gp.cards_remaining >= target_hand:
+ break
+ entry = GameDeck.get_top_card(game)
+ if not entry:
+ break
+ card = entry.card
+ entry.delete()
+ max_order = PlayerHand.objects.filter(game=game, player=user).aggregate(
+ m=Max("order_in_hand")
+ )["m"]
+ next_order = (max_order or 0) + 1
+ PlayerHand.objects.create(
+ game=game,
+ player=user,
+ card=card,
+ order_in_hand=next_order,
+ )
+ gp.cards_remaining += 1
+ gp.save(update_fields=["cards_remaining"])
+
+
+def _draw_round_after_discard(game: Game, start_user_id: UUID):
+ order = _player_circle(game)
+ idx = next(i for i, p in enumerate(order) if p.user_id == start_user_id)
+ n = len(order)
+ for k in range(n):
+ _draw_for_player(game, order[(idx + k) % n].user, 6)
+ _refresh_card_counts(game)
+
+
+def _maybe_finish_game(game: Game):
+ if game.status != "in_progress":
+ return
+ deck_empty = not GameDeck.objects.filter(game=game).exists()
+ if not deck_empty:
+ return
+ holders = [gp for gp in _player_circle(game) if gp.cards_remaining > 0]
+ if len(holders) == 1:
+ game.status = "finished"
+ game.loser = holders[0].user
+ game.finished_at = timezone.now()
+ game.save(update_fields=["status", "loser", "finished_at"])
+ lobby = game.lobby
+ lobby.status = "waiting"
+ lobby.save(update_fields=["status"])
+ LobbyPlayer.objects.filter(lobby=lobby, status="playing").update(status="waiting")
+ broadcast_game(game.id, "game_finished", {"loser_id": str(game.loser_id)})
+ broadcast_lobby(lobby.id, "game_finished", {"game_id": str(game.id)})
+
+
+def _rotate_roles_after_success(game: Game):
+ rs = _rs(game)
+ old_def = UUID(rs["defender_id"])
+ players = _player_circle(game)
+ idx_d = next(i for i, p in enumerate(players) if p.user_id == old_def)
+ new_att = old_def
+ new_def = players[(idx_d + 1) % len(players)].user_id
+ _save_rs(game, attacker_id=str(new_att), defender_id=str(new_def), phase=PHASE_BETWEEN)
+
+
+def _clear_table_to_discard(game: Game):
+ cards: list[Card] = []
+ for tc in TableCard.objects.filter(game=game).order_by("id"):
+ cards.append(tc.attack_card)
+ if tc.defense_card:
+ cards.append(tc.defense_card)
+ TableCard.objects.filter(game=game).delete()
+ if cards:
+ DiscardPile.discard_cards(game, cards)
+
+
+def _clear_table_to_hand(game: Game, user: User):
+ for tc in TableCard.objects.filter(game=game).order_by("id"):
+ for c in (tc.attack_card, tc.defense_card):
+ if not c:
+ continue
+ max_order = PlayerHand.objects.filter(game=game, player=user).aggregate(
+ m=Max("order_in_hand")
+ )["m"]
+ next_order = (max_order or 0) + 1
+ PlayerHand.objects.create(
+ game=game,
+ player=user,
+ card=c,
+ order_in_hand=next_order,
+ )
+ TableCard.objects.filter(game=game).delete()
+ _refresh_card_counts(game)
+
+
+def _table_attack_ranks(game: Game) -> set[int]:
+ """Ranks that may be used for throw-ins: any rank shown on the table (attack or defense).
+
+ Classic podkidnoy allows matching ranks from beaten pairs, not only the original attacks.
+
+ Resolves ranks via :class:`~game.models.Card` rows (not multi-hop ``values_list`` on
+ nullable defense FKs) so every card on the table contributes reliably.
+ """
+ pairs = TableCard.objects.filter(game=game).values_list(
+ "attack_card_id", "defense_card_id"
+ )
+ card_ids: set[UUID] = set()
+ for attack_id, defense_id in pairs:
+ card_ids.add(attack_id)
+ if defense_id:
+ card_ids.add(defense_id)
+ if not card_ids:
+ return set()
+ return {
+ int(v)
+ for v in Card.objects.filter(id__in=card_ids).values_list("rank__value", flat=True)
+ }
+
+
+def _defender_hand_size(game: Game, defender_id: UUID) -> int:
+ return PlayerHand.objects.filter(game=game, player_id=defender_id).count()
+
+
+def _count_undefended(game: Game) -> int:
+ return TableCard.objects.filter(game=game, defense_card__isnull=True).count()
+
+
+def _all_defended(game: Game) -> bool:
+ qs = TableCard.objects.filter(game=game)
+ return qs.exists() and not qs.filter(defense_card__isnull=True).exists()
+
+
+def leave_other_active_lobbies(user: User, *, except_lobby: Lobby | None = None):
+ """Leave every active lobby membership except ``except_lobby`` (if given).
+
+ Ensures a user is only seated in one waiting/playing lobby at a time.
+
+ Args:
+ user: Player to detach from extra rooms.
+ except_lobby: Lobby id to skip (e.g. the room being joined).
+ """
+ qs = LobbyPlayer.objects.filter(user=user).exclude(status="left")
+ if except_lobby is not None:
+ qs = qs.exclude(lobby_id=except_lobby.id)
+ for lp in list(qs):
+ leave_lobby(lp.lobby, user)
+
+
+@transaction.atomic
+def create_lobby(
+ owner: User,
+ name: str,
+ *,
+ is_private: bool = False,
+ password: str | None = None,
+ max_players: int = 4,
+ card_count: int = 36,
+ is_transferable: bool = False,
+ neighbor_throw_only: bool = False,
+ allow_jokers: bool = False,
+ turn_time_limit: int | None = None,
+) -> Lobby:
+ """Create a lobby, default :class:`~game.models.LobbySettings`, and owner seat.
+
+ Args:
+ owner: Authenticated user who becomes lobby owner.
+ name: Display name.
+ is_private: Whether a password is required to join.
+ password: Plain text; stored hashed when private.
+ max_players: Upper bound enforced by :meth:`game.models.Lobby.is_full`.
+ card_count: Deck size (24, 36, or 52).
+ is_transferable: Rule flag (reserved for future transfer logic).
+ neighbor_throw_only: Restrict throw-in to defender's neighbors.
+ allow_jokers: Whether jokers may appear in the shoe (requires data).
+ turn_time_limit: Optional per-turn cap in seconds.
+
+ Returns:
+ The newly created :class:`~game.models.Lobby` instance.
+ """
+ leave_other_active_lobbies(owner, except_lobby=None)
+ lobby = Lobby.objects.create(
+ owner=owner,
+ name=name,
+ is_private=is_private,
+ password_hash=make_password(password) if (is_private and password) else None,
+ status="waiting",
+ )
+ LobbySettings.objects.create(
+ lobby=lobby,
+ max_players=max_players,
+ card_count=card_count,
+ is_transferable=is_transferable,
+ neighbor_throw_only=neighbor_throw_only,
+ allow_jokers=allow_jokers,
+ turn_time_limit=turn_time_limit,
+ )
+ LobbyPlayer.objects.create(lobby=lobby, user=owner, status="waiting")
+ get_lobby_chat(lobby)
+ broadcast_lobby(lobby.id, "lobby_created", {"lobby_id": str(lobby.id)})
+ return lobby
+
+
+@transaction.atomic
+def join_lobby(lobby: Lobby, user: User, password: str | None = None) -> LobbyPlayer:
+ """Add ``user`` to ``lobby`` or reactivate a previously left membership.
+
+ Args:
+ lobby: Target lobby.
+ user: Joining user.
+ password: Required plaintext password when ``lobby.is_private``.
+
+ Returns:
+ The active :class:`~game.models.LobbyPlayer` row.
+
+ Raises:
+ GameError: If the lobby is full, closed, password is wrong, or duplicate.
+ """
+ if lobby.status == "closed":
+ raise GameError("Lobby is closed", "closed")
+ leave_other_active_lobbies(user, except_lobby=lobby)
+ if lobby.is_full():
+ raise GameError("Lobby is full", "full")
+ if lobby.is_private:
+ if not lobby.password_hash or not check_password(password or "", lobby.password_hash):
+ raise GameError("Invalid password", "auth")
+ if LobbyPlayer.objects.filter(lobby=lobby, user=user).exclude(status="left").exists():
+ raise GameError("Already in lobby", "duplicate")
+ if LobbyPlayer.objects.filter(user=user, lobby=lobby, status="left").exists():
+ lp = LobbyPlayer.objects.get(user=user, lobby=lobby)
+ lp.status = "waiting"
+ lp.save(update_fields=["status"])
+ else:
+ lp = LobbyPlayer.objects.create(lobby=lobby, user=user, status="waiting")
+ get_lobby_chat(lobby)
+ broadcast_lobby(lobby.id, "player_joined", {"user_id": str(user.id)})
+ return lp
+
+
+@transaction.atomic
+def leave_lobby(lobby: Lobby, user: User) -> dict:
+ """Mark every active membership of ``user`` in ``lobby`` as left.
+
+ If nobody remains, the lobby is closed. If the owner leaves but others stay,
+ ownership moves to another member (alphabetically by username).
+
+ Args:
+ lobby: Lobby to exit.
+ user: Leaving user.
+
+ Returns:
+ Dict with ``lobby_closed`` (bool) and ``new_owner_id`` (optional str).
+
+ Raises:
+ GameError: If the user had no active membership.
+ """
+ qs = LobbyPlayer.objects.filter(lobby=lobby, user=user).exclude(status="left")
+ if not qs.exists():
+ raise GameError("Not in lobby", "not_found")
+ was_owner = lobby.owner_id == user.id
+ for lp in qs:
+ lp.leave_lobby()
+ remove_user_from_lobby_chat(lobby, user)
+
+ lobby_closed = False
+ new_owner_id: str | None = None
+ remaining = LobbyPlayer.objects.filter(lobby=lobby).exclude(status="left")
+ if not remaining.exists():
+ if lobby.status != "closed":
+ lobby.status = "closed"
+ lobby.save(update_fields=["status"])
+ lobby_closed = True
+ elif was_owner:
+ next_lp = remaining.order_by("user__username").first()
+ lobby.owner = next_lp.user
+ lobby.save(update_fields=["owner"])
+ new_owner_id = str(next_lp.user_id)
+
+ broadcast_lobby(lobby.id, "player_left", {"user_id": str(user.id)})
+ return {"lobby_closed": lobby_closed, "new_owner_id": new_owner_id}
+
+
+@transaction.atomic
+def set_ready(lobby: Lobby, user: User, ready: bool):
+ """Toggle waiting/ready status for a member.
+
+ Args:
+ lobby: Lobby context.
+ user: Member toggling readiness.
+ ready: ``True`` for ready, ``False`` for waiting.
+
+ Raises:
+ GameError: If ``user`` is not an active member.
+ """
+ lp = LobbyPlayer.objects.filter(lobby=lobby, user=user).exclude(status="left").first()
+ if not lp:
+ raise GameError("Not in lobby", "not_found")
+ lp.status = "ready" if ready else "waiting"
+ lp.save(update_fields=["status"])
+ broadcast_lobby(lobby.id, "ready_changed", {"user_id": str(user.id), "ready": ready})
+
+
+@transaction.atomic
+def start_game(lobby: Lobby, user: User) -> Game:
+ """Deal cards, set trump, and spawn an in-progress :class:`~game.models.Game`.
+
+ Args:
+ lobby: Must be in ``waiting`` with at least two ``ready`` members.
+ user: Lobby owner (only owners may start).
+
+ Returns:
+ Fresh :class:`~game.models.Game` in ``between`` phase.
+
+ Raises:
+ GameError: On permission, deck data, duplicate active game, or rules.
+ """
+ if lobby.owner_id != user.id:
+ raise GameError("Only owner can start", "forbidden")
+ if Game.objects.filter(lobby=lobby, status="in_progress").exists():
+ raise GameError("Game already in progress", "state")
+ if not lobby.can_start_game():
+ raise GameError("Cannot start game", "precondition")
+ settings = lobby.settings
+ ready_players = list(
+ lobby.players.filter(status="ready").select_related("user").order_by("user__username")
+ )
+ if len(ready_players) < 2:
+ raise GameError("Need at least 2 ready players", "precondition")
+
+ lobby.status = "playing"
+ lobby.save(update_fields=["status"])
+ for lp in lobby.players.filter(status__in=["waiting", "ready"]):
+ if lp in ready_players:
+ lp.status = "playing"
+ lp.save(update_fields=["status"])
+ else:
+ lp.leave_lobby()
+
+ if settings.card_count not in _SHOE_CARD_COUNT:
+ raise GameError("Unsupported card_count for this lobby", "config")
+ ensure_playing_cards_for_deck_size(settings.card_count)
+ card_ids = _deck_card_ids_for_shoe(settings)
+ expected = _SHOE_CARD_COUNT.get(settings.card_count)
+ if expected is not None and len(card_ids) != expected:
+ raise GameError(
+ f"Deck data mismatch after auto-seed: {len(card_ids)} unique suit/rank cards, "
+ f"need {expected} for a {settings.card_count}-card shoe",
+ "config",
+ )
+ if len(card_ids) < 12:
+ raise GameError("Not enough cards in database for this deck size", "config")
+ if len(card_ids) != len(set(card_ids)):
+ raise GameError(
+ "Deck build produced duplicate card ids; run: python manage.py init_game_data",
+ "config",
+ )
+ secrets.SystemRandom().shuffle(card_ids)
+ trump_id = card_ids[-1]
+ rest = card_ids[:-1]
+
+ game = Game.objects.create(
+ lobby=lobby,
+ trump_card_id=trump_id,
+ status="in_progress",
+ runtime_state={},
+ )
+ for pos, cid in enumerate(rest):
+ GameDeck.objects.create(game=game, card_id=cid, position=pos)
+
+ for seat, lp in enumerate(ready_players, start=1):
+ GamePlayer.objects.create(
+ game=game,
+ user=lp.user,
+ seat_position=seat,
+ cards_remaining=0,
+ )
+
+ trump_card = Card.objects.get(id=trump_id)
+ for gp in _player_circle(game):
+ for _ in range(6):
+ entry = GameDeck.get_top_card(game)
+ if not entry:
+ break
+ c = entry.card
+ entry.delete()
+ PlayerHand.objects.create(
+ game=game,
+ player=gp.user,
+ card=c,
+ order_in_hand=None,
+ )
+ _refresh_card_counts(game)
+
+ first_attacker = _pick_first_attacker(game, trump_card.suit_id)
+ players = _player_circle(game)
+ idx_a = next(i for i, p in enumerate(players) if p.user_id == first_attacker)
+ defender = players[(idx_a + 1) % len(players)].user_id
+ _save_rs(
+ game,
+ attacker_id=str(first_attacker),
+ defender_id=str(defender),
+ phase=PHASE_BETWEEN,
+ )
+
+ broadcast_lobby(lobby.id, "game_started", {"game_id": str(game.id)})
+ broadcast_game(game.id, "game_started", {})
+ return game
+
+
+def serialize_lobby(lobby: Lobby) -> dict:
+ """Build a JSON-serializable lobby snapshot for REST responses.
+
+ Args:
+ lobby: Lobby including related settings and players.
+
+ Returns:
+ Dict with ids as strings, nested ``settings``, ``players``, and optional
+ ``active_game_id``.
+ """
+ settings = lobby.settings
+ return {
+ "id": str(lobby.id),
+ "name": lobby.name,
+ "owner_id": str(lobby.owner_id),
+ "is_private": lobby.is_private,
+ "status": lobby.status,
+ "created_at": lobby.created_at.isoformat(),
+ "settings": {
+ "max_players": settings.max_players,
+ "card_count": settings.card_count,
+ "is_transferable": settings.is_transferable,
+ "neighbor_throw_only": settings.neighbor_throw_only,
+ "allow_jokers": settings.allow_jokers,
+ "turn_time_limit": settings.turn_time_limit,
+ },
+ "players": [
+ {
+ "user_id": str(p.user_id),
+ "username": p.user.username,
+ "status": p.status,
+ }
+ for p in lobby.players.exclude(status="left").select_related("user")
+ ],
+ "active_game_id": _active_game_id(lobby),
+ "can_start": lobby.can_start_game(),
+ "ready_count": lobby.players.filter(status="ready").count(),
+ "active_player_count": lobby.players.exclude(status="left").count(),
+ }
+
+
+def _active_game_id(lobby: Lobby) -> str | None:
+ g = Game.objects.filter(lobby=lobby, status="in_progress").order_by("-started_at").first()
+ return str(g.id) if g else None
+
+
+def serialize_game(game: Game, viewer: User) -> dict:
+ """Return game state visible to ``viewer`` (only their hand is revealed).
+
+ Args:
+ game: Game to serialize.
+ viewer: Authenticated subject; opponents see counts only.
+
+ Returns:
+ Dict with ``table``, ``runtime``, ``trump_card``, ``deck_remaining``, etc.
+ """
+ _refresh_card_counts(game)
+ trump = game.trump_card
+ rs = _rs(game)
+ table = []
+ for tc in TableCard.objects.filter(game=game).select_related(
+ "attack_card__suit", "attack_card__rank", "defense_card__suit", "defense_card__rank"
+ ):
+ table.append(
+ {
+ "id": str(tc.id),
+ "attack": _card_json(tc.attack_card),
+ "defense": _card_json(tc.defense_card) if tc.defense_card else None,
+ }
+ )
+ players_out = []
+ for gp in _player_circle(game):
+ hand = None
+ if gp.user_id == viewer.id:
+ hand = [
+ {**_card_json(ph.card), "hand_id": str(ph.id)}
+ for ph in PlayerHand.objects.filter(game=game, player=gp.user)
+ .select_related("card__suit", "card__rank")
+ .order_by("order_in_hand", "id")
+ ]
+ players_out.append(
+ {
+ "user_id": str(gp.user_id),
+ "username": gp.user.username,
+ "seat_position": gp.seat_position,
+ "cards_remaining": gp.cards_remaining,
+ "hand": hand,
+ }
+ )
+ deck_left = GameDeck.objects.filter(game=game).count()
+ return {
+ "id": str(game.id),
+ "lobby_id": str(game.lobby_id),
+ "status": game.status,
+ "trump_card": _card_json(trump),
+ "deck_remaining": deck_left,
+ "runtime": {
+ "attacker_id": rs.get("attacker_id"),
+ "defender_id": rs.get("defender_id"),
+ "phase": rs.get("phase"),
+ },
+ "players": players_out,
+ "table": table,
+ "loser_id": str(game.loser_id) if game.loser_id else None,
+ }
+
+
+def _card_json(card: Card | None) -> dict | None:
+ if not card:
+ return None
+ return {
+ "id": str(card.id),
+ "suit": card.suit.name,
+ "rank": card.rank.name,
+ "value": card.rank.value,
+ }
+
+
+def _ensure_turn(game: Game, user: User) -> Turn:
+ t = Turn.get_current_turn(game)
+ if t and t.player_id == user.id:
+ return t
+ return Turn.create_next_turn(game, user)
+
+
+@transaction.atomic
+def play_attack(game: Game, user: User, card_ids: list[UUID]):
+ """Place attacking or throw-in cards from the actor's hand onto the table.
+
+ Args:
+ game: Active game.
+ user: Attacker (open or extend) or throw-in player during ``build``.
+ card_ids: Cards currently held by ``user``.
+
+ Raises:
+ GameError: On wrong phase, illegal ranks, capacity, or ownership.
+ """
+ if game.status != "in_progress":
+ raise GameError("Game not active", "finished")
+ rs = _rs(game)
+ phase = rs.get("phase")
+ if phase not in (PHASE_BUILD, PHASE_BETWEEN, PHASE_DEFEND):
+ raise GameError("Cannot attack now", "phase")
+
+ settings = game.lobby.settings
+ attacker_id = UUID(rs["attacker_id"])
+ defender_id = UUID(rs["defender_id"])
+
+ if phase == PHASE_BETWEEN:
+ if user.id != attacker_id:
+ raise GameError("Only attacker may open", "turn")
+ if TableCard.objects.filter(game=game).exists():
+ raise GameError("Table must be empty", "state")
+ if not card_ids:
+ raise GameError("Play at least one card", "cards")
+ cards = _cards_in_hand(game, user, card_ids)
+ ranks = {c.rank_id for c in cards}
+ if len(ranks) != 1:
+ raise GameError("Attack cards must share rank", "cards")
+ _save_rs(game, phase=PHASE_BUILD)
+ else:
+ if user.id == defender_id:
+ raise GameError("Defender cannot attack", "turn")
+ if user.id != attacker_id:
+ if phase not in (PHASE_BUILD, PHASE_DEFEND):
+ raise GameError("Cannot throw in now", "phase")
+ eligible = {attacker_id, defender_id}
+ others = {p.user_id for p in _player_circle(game)} - eligible
+ if user.id not in others:
+ raise GameError("Only other players may throw in", "turn")
+ if settings.neighbor_throw_only:
+ if user.id not in _neighbor_user_ids(game, defender_id):
+ raise GameError("Only neighbors may throw in", "rules")
+ if not card_ids:
+ raise GameError("No cards", "cards")
+ cards = _cards_in_hand(game, user, card_ids)
+ allowed_ranks = _table_attack_ranks(game)
+ for c in cards:
+ if int(c.rank.value) not in allowed_ranks:
+ raise GameError(
+ f"Card rank must match table (got {c.rank.name}={c.rank.value}; "
+ f"allowed values: {sorted(allowed_ranks)})",
+ "cards",
+ )
+ max_cards = _defender_hand_size(game, defender_id)
+ on_table = TableCard.objects.filter(game=game).count()
+ if on_table + len(cards) > max_cards:
+ raise GameError("Too many cards on table", "rules")
+
+ turn = _ensure_turn(game, user)
+ for c in cards:
+ ph = PlayerHand.objects.get(game=game, player=user, card=c)
+ ph.remove_from_hand()
+ tc = TableCard.objects.create(game=game, attack_card=c)
+ Move.objects.create(turn=turn, table_card=tc, action_type="attack")
+
+ _refresh_card_counts(game)
+ broadcast_game(game.id, "game_update", {})
+ _maybe_finish_game(game)
+
+
+def _cards_in_hand(game: Game, user: User, card_ids: Iterable[UUID]) -> list[Card]:
+ ids = list(card_ids)
+ hands = list(
+ PlayerHand.objects.filter(game=game, player=user, card_id__in=ids).select_related(
+ "card__rank"
+ )
+ )
+ if len(hands) != len(ids):
+ raise GameError("Invalid hand cards", "cards")
+ return [h.card for h in hands]
+
+
+@transaction.atomic
+def seal_attack(game: Game, user: User):
+ """Legacy no-op: defense can start during ``build`` without a separate seal step.
+
+ Kept for API compatibility.
+ """
+ if game.status != "in_progress":
+ raise GameError("Game not active", "finished")
+ rs = _rs(game)
+ if UUID(rs["attacker_id"]) != user.id:
+ raise GameError("Only attacker can seal", "turn")
+ if rs.get("phase") not in (PHASE_BUILD, PHASE_DEFEND):
+ raise GameError("Not in attack wave", "phase")
+ if not TableCard.objects.filter(game=game).exists():
+ raise GameError("Nothing to seal", "state")
+ broadcast_game(game.id, "game_update", {})
+
+
+@transaction.atomic
+def defend(game: Game, user: User, table_card_id: UUID, card_id: UUID):
+ """Cover a single attack row with a legal defense card.
+
+ Args:
+ game: Active game during the table wave (``build`` or legacy ``defend``).
+ user: Defender.
+ table_card_id: Row to beat.
+ card_id: Card from defender's hand.
+
+ Raises:
+ GameError: If the defense is illegal or row already covered.
+ """
+ if game.status != "in_progress":
+ raise GameError("Game not active", "finished")
+ rs = _rs(game)
+ if UUID(rs["defender_id"]) != user.id:
+ raise GameError("Only defender acts", "turn")
+ if rs.get("phase") not in (PHASE_BUILD, PHASE_DEFEND):
+ raise GameError("Not defense phase", "phase")
+ tc = TableCard.objects.select_related("attack_card").get(game=game, id=table_card_id)
+ if tc.defense_card_id:
+ raise GameError("Already defended", "state")
+ defense_card = PlayerHand.objects.get(game=game, player=user, card_id=card_id).card
+ trump = game.trump_card.suit
+ if not tc.is_valid_defense(defense_card, trump):
+ raise GameError("Illegal defense card", "cards")
+ ph = PlayerHand.objects.get(game=game, player=user, card=defense_card)
+ ph.remove_from_hand()
+ tc.defense_card = defense_card
+ tc.save(update_fields=["defense_card"])
+ turn = _ensure_turn(game, user)
+ Move.objects.create(turn=turn, table_card=tc, action_type="defend")
+ _refresh_card_counts(game)
+ broadcast_game(game.id, "game_update", {})
+ _maybe_finish_game(game)
+
+
+@transaction.atomic
+def take_table(game: Game, user: User):
+ """Defender picks up the whole table; hands refill from the deck.
+
+ Args:
+ game: Active game in ``defend``.
+ user: Defender.
+
+ Raises:
+ GameError: If phase or role is wrong.
+ """
+ if game.status != "in_progress":
+ raise GameError("Game not active", "finished")
+ rs = _rs(game)
+ if UUID(rs["defender_id"]) != user.id:
+ raise GameError("Only defender may take", "turn")
+ if rs.get("phase") not in (PHASE_BUILD, PHASE_DEFEND):
+ raise GameError("Not defense phase", "phase")
+ if not TableCard.objects.filter(game=game).exists():
+ raise GameError("Table empty", "state")
+
+ turn = _ensure_turn(game, user)
+ for tc in TableCard.objects.filter(game=game):
+ Move.objects.create(turn=turn, table_card=tc, action_type="pickup")
+
+ _clear_table_to_hand(game, user)
+ _save_rs(game, phase=PHASE_BETWEEN)
+ attacker = UUID(rs["attacker_id"])
+ _draw_round_after_discard(game, attacker)
+ broadcast_game(game.id, "game_update", {})
+ _maybe_finish_game(game)
+
+
+@transaction.atomic
+def bito(game: Game, user: User):
+ """Attacker confirms successful defense; discards table and rotates roles.
+
+ Args:
+ game: Active game where every row is defended.
+ user: Primary attacker.
+
+ Raises:
+ GameError: If any row is still open or caller is not the attacker.
+ """
+ if game.status != "in_progress":
+ raise GameError("Game not active", "finished")
+ rs = _rs(game)
+ if UUID(rs["attacker_id"]) != user.id:
+ raise GameError("Only attacker can call bito", "turn")
+ if rs.get("phase") not in (PHASE_BUILD, PHASE_DEFEND):
+ raise GameError("Wrong phase", "phase")
+ if not _all_defended(game):
+ raise GameError("Not all cards defended", "state")
+
+ _clear_table_to_discard(game)
+ old_att = UUID(rs["attacker_id"])
+ _rotate_roles_after_success(game)
+ _draw_round_after_discard(game, old_att)
+ broadcast_game(game.id, "game_update", {})
+ _maybe_finish_game(game)
diff --git a/game/templates/game/lobby_detail.html b/game/templates/game/lobby_detail.html
new file mode 100644
index 0000000..a433343
--- /dev/null
+++ b/game/templates/game/lobby_detail.html
@@ -0,0 +1,282 @@
+{% extends "base.html" %}
+{% load static %}
+{% block body_class %}game-skin{% endblock %}
+{% block title %}Lobby — Fools Arena{% endblock %}
+{% block extra_head %}
+
+{% endblock %}
+{% block content %}
+
+
← All lobbies
+
+
+
+ Lobby
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/game/templates/game/lobby_list.html b/game/templates/game/lobby_list.html
new file mode 100644
index 0000000..69de88e
--- /dev/null
+++ b/game/templates/game/lobby_list.html
@@ -0,0 +1,125 @@
+{% extends "base.html" %}
+{% load static %}
+{% block body_class %}game-skin{% endblock %}
+{% block title %}Lobbies — Fools Arena{% endblock %}
+{% block extra_head %}
+
+{% endblock %}
+{% block content %}
+
+
+ Open lobbies
+ Loading…
+
+
+
+
+
+
+{% endblock %}
diff --git a/game/templates/game/play.html b/game/templates/game/play.html
new file mode 100644
index 0000000..9dba127
--- /dev/null
+++ b/game/templates/game/play.html
@@ -0,0 +1,48 @@
+{% extends "base.html" %}
+{% load static %}
+{% block body_class %}game-skin{% endblock %}
+{% block title %}Table — Durak — Fools Arena{% endblock %}
+{% block extra_head %}
+
+{% endblock %}
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/game/tests/conftest.py b/game/tests/conftest.py
new file mode 100644
index 0000000..184f45c
--- /dev/null
+++ b/game/tests/conftest.py
@@ -0,0 +1,14 @@
+"""Pytest fixtures for game API and service tests."""
+
+import pytest
+from django.core.management import call_command
+
+
+@pytest.fixture
+def durak_deck_36(db):
+ """Ensure a standard 36-card deck exists in the database (idempotent).
+
+ Returns:
+ None: Modifies the database in place.
+ """
+ call_command("init_game_data", deck_size=36)
diff --git a/game/tests/test_card_catalog.py b/game/tests/test_card_catalog.py
new file mode 100644
index 0000000..1d96a85
--- /dev/null
+++ b/game/tests/test_card_catalog.py
@@ -0,0 +1,31 @@
+"""Tests for :mod:`game.card_catalog` merge behavior."""
+
+import pytest
+
+from game.card_catalog import _reassign_card_pk
+from game.models import Card, CardRank, CardSuit, Game, GamePlayer, PlayerHand
+from game.services import create_lobby, join_lobby
+
+
+@pytest.mark.django_db
+def test_reassign_merges_duplicate_hand_rows_without_unique_violation(
+ test_user, second_user, durak_deck_36
+):
+ """If a player holds two DB rows for the same suit/rank, merge drops the extra hand row."""
+ hearts = CardSuit.objects.get(name="Hearts")
+ ace = CardRank.objects.get(value=14)
+ c1 = (
+ Card.objects.filter(suit=hearts, rank=ace, special_card__isnull=True)
+ .order_by("id")
+ .first()
+ )
+ c2 = Card.objects.create(suit=hearts, rank=ace)
+ lobby = create_lobby(test_user, "merge-hand", is_private=False)
+ join_lobby(lobby, second_user)
+ game = Game.objects.create(lobby=lobby, trump_card=c1, status="in_progress")
+ GamePlayer.objects.create(game=game, user=test_user, seat_position=1, cards_remaining=2)
+ PlayerHand.objects.create(game=game, player=test_user, card=c1, order_in_hand=1)
+ PlayerHand.objects.create(game=game, player=test_user, card=c2, order_in_hand=2)
+ _reassign_card_pk(c2.pk, c1.pk)
+ assert PlayerHand.objects.filter(game=game, player=test_user, card=c1).count() == 1
+ assert not Card.objects.filter(pk=c2.pk).exists()
diff --git a/game/tests/test_game_api.py b/game/tests/test_game_api.py
new file mode 100644
index 0000000..d631532
--- /dev/null
+++ b/game/tests/test_game_api.py
@@ -0,0 +1,96 @@
+"""HTTP tests for `/api/game/` lobby and gameplay endpoints."""
+
+import pytest
+from django.urls import reverse
+from rest_framework import status
+from rest_framework.test import APIClient
+
+from game.models import Game
+
+
+@pytest.mark.django_db
+class TestGameLobbyAPI:
+ """Integration tests using DRF APIClient and session auth."""
+
+ @pytest.fixture
+ def api(self):
+ """Returns:
+ APIClient: Unauthenticated REST client.
+ """
+ return APIClient()
+
+ def test_list_requires_auth(self, api):
+ """Unauthenticated GET on lobby list should return 403."""
+ url = reverse("api-lobby-list")
+ r = api.get(url)
+ assert r.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_create_and_list_lobby(self, api, test_user, durak_deck_36):
+ """Authenticated user can create a lobby and see it in the public list."""
+ api.force_login(test_user)
+ create_url = reverse("api-lobby-list")
+ r = api.post(
+ create_url,
+ {
+ "name": "API Room",
+ "is_private": False,
+ "max_players": 4,
+ "card_count": 36,
+ },
+ format="json",
+ )
+ assert r.status_code == status.HTTP_201_CREATED
+ lobby_id = r.data["id"]
+ r2 = api.get(create_url)
+ assert r2.status_code == status.HTTP_200_OK
+ assert any(row["id"] == lobby_id for row in r2.data)
+
+ def test_join_ready_start_flow(self, api, test_user, second_user, durak_deck_36):
+ """Two players: join, ready, owner starts — returns 201 with game payload."""
+ api.force_login(test_user)
+ r = api.post(
+ reverse("api-lobby-list"),
+ {"name": "Flow", "is_private": False, "card_count": 36, "max_players": 4},
+ format="json",
+ )
+ assert r.status_code == status.HTTP_201_CREATED
+ lid = r.data["id"]
+ api.force_login(second_user)
+ rj = api.post(reverse("api-lobby-join", kwargs={"lobby_id": lid}), {}, format="json")
+ assert rj.status_code == status.HTTP_200_OK
+ api.force_login(test_user)
+ api.post(reverse("api-lobby-ready", kwargs={"lobby_id": lid}), {"ready": True}, format="json")
+ api.force_login(second_user)
+ api.post(reverse("api-lobby-ready", kwargs={"lobby_id": lid}), {"ready": True}, format="json")
+ api.force_login(test_user)
+ rs = api.post(reverse("api-lobby-start", kwargs={"lobby_id": lid}), {}, format="json")
+ assert rs.status_code == status.HTTP_201_CREATED
+ assert "id" in rs.data
+ assert Game.objects.filter(id=rs.data["id"]).exists()
+
+ def test_game_state_for_player(self, api, test_user, second_user, durak_deck_36):
+ """GET game state returns runtime phase and masked hands for opponents."""
+ api.force_login(test_user)
+ r = api.post(
+ reverse("api-lobby-list"),
+ {"name": "State", "is_private": False, "card_count": 36, "max_players": 4},
+ format="json",
+ )
+ lid = r.data["id"]
+ api.force_login(second_user)
+ api.post(reverse("api-lobby-join", kwargs={"lobby_id": lid}), {}, format="json")
+ api.force_login(test_user)
+ api.post(reverse("api-lobby-ready", kwargs={"lobby_id": lid}), {"ready": True}, format="json")
+ api.force_login(second_user)
+ api.post(reverse("api-lobby-ready", kwargs={"lobby_id": lid}), {"ready": True}, format="json")
+ api.force_login(test_user)
+ rs = api.post(reverse("api-lobby-start", kwargs={"lobby_id": lid}), {}, format="json")
+ gid = rs.data["id"]
+ detail = api.get(reverse("api-game-state", kwargs={"game_id": gid}))
+ assert detail.status_code == status.HTTP_200_OK
+ assert detail.data["status"] == "in_progress"
+ assert detail.data["runtime"]["phase"] == "between"
+ me = next(p for p in detail.data["players"] if p["user_id"] == str(test_user.id))
+ other = next(p for p in detail.data["players"] if p["user_id"] != str(test_user.id))
+ assert me["hand"] is not None
+ assert other["hand"] is None
diff --git a/game/tests/test_game_services.py b/game/tests/test_game_services.py
new file mode 100644
index 0000000..cfba2ef
--- /dev/null
+++ b/game/tests/test_game_services.py
@@ -0,0 +1,241 @@
+"""Unit tests for `game.services` lobby and gameplay helpers."""
+
+import pytest
+from django.contrib.auth import get_user_model
+
+from game.models import (
+ Card,
+ CardRank,
+ CardSuit,
+ Game,
+ GameDeck,
+ GamePlayer,
+ Lobby,
+ LobbyPlayer,
+ LobbySettings,
+ PlayerHand,
+ TableCard,
+)
+from game.services import (
+ GameError,
+ PHASE_BUILD,
+ _table_attack_ranks,
+ create_lobby,
+ join_lobby,
+ play_attack,
+ set_ready,
+ start_game,
+)
+
+User = get_user_model()
+
+
+@pytest.mark.django_db
+class TestLobbyServices:
+ """Tests for lobby creation, joining, and game start preconditions."""
+
+ def test_create_public_lobby(self, test_user):
+ """A public lobby should persist with settings and owner membership."""
+ lobby = create_lobby(test_user, "Room A", is_private=False)
+ assert lobby.name == "Room A"
+ assert not lobby.is_private
+ assert LobbyPlayer.objects.filter(lobby=lobby, user=test_user).exists()
+
+ def test_private_lobby_requires_password_on_create(self, test_user):
+ """Serializer layer enforces password; service still stores hash when given."""
+ lobby = create_lobby(
+ test_user, "Secret", is_private=True, password="hunter2"
+ )
+ assert lobby.is_private
+ assert lobby.password_hash
+
+ def test_join_wrong_password(self, test_user, second_user, user_factory):
+ """Joining a private lobby with a bad password raises GameError."""
+ host = user_factory(username="host_x")
+ lobby = create_lobby(host, "P", is_private=True, password="ok")
+ with pytest.raises(GameError) as exc:
+ join_lobby(lobby, test_user, password="nope")
+ assert exc.value.code == "auth"
+
+ def test_start_game_requires_owner(self, test_user, second_user, durak_deck_36):
+ """Only the lobby owner may call `start_game`."""
+ lobby = create_lobby(test_user, "G1", is_private=False)
+ join_lobby(lobby, second_user)
+ set_ready(lobby, test_user, True)
+ set_ready(lobby, second_user, True)
+ with pytest.raises(GameError) as exc:
+ start_game(lobby, second_user)
+ assert exc.value.code == "forbidden"
+
+ def test_start_game_happy_path(self, test_user, second_user, durak_deck_36):
+ """Two ready players and a seeded deck produce an in-progress `Game`."""
+ lobby = create_lobby(test_user, "G2", is_private=False)
+ join_lobby(lobby, second_user)
+ set_ready(lobby, test_user, True)
+ set_ready(lobby, second_user, True)
+ game = start_game(lobby, test_user)
+ assert game.status == "in_progress"
+ assert game.players.count() == 2
+ assert "attacker_id" in (game.runtime_state or {})
+ lobby.refresh_from_db()
+ assert lobby.status == "playing"
+
+ def test_start_game_dedupes_duplicate_suit_rank_rows(self, test_user, second_user, durak_deck_36):
+ """Duplicate ``Card`` rows for the same suit+rank must not appear in one shoe."""
+ hearts = CardSuit.objects.get(name="Hearts")
+ ace = CardRank.objects.get(value=14)
+ Card.objects.create(suit=hearts, rank=ace)
+ lobby = create_lobby(test_user, "G-dedupe", is_private=False)
+ join_lobby(lobby, second_user)
+ set_ready(lobby, test_user, True)
+ set_ready(lobby, second_user, True)
+ game = start_game(lobby, test_user)
+ hand_ids = list(PlayerHand.objects.filter(game=game).values_list("card_id", flat=True))
+ assert len(hand_ids) == len(set(hand_ids))
+ suit_rank = {
+ (ph.card.suit_id, ph.card.rank_id)
+ for ph in PlayerHand.objects.filter(game=game).select_related("card")
+ }
+ assert len(suit_rank) == len(hand_ids)
+ trump_id = game.trump_card_id
+ assert trump_id not in hand_ids
+ assert not GameDeck.objects.filter(game=game, card_id=trump_id).exists()
+
+ def test_start_game_auto_seeds_cards_when_missing(self, test_user, second_user, durak_deck_36):
+ """If normal playing cards were removed, ``start_game`` repopulates the catalog."""
+ lobby = create_lobby(test_user, "G-autoseed", is_private=False)
+ join_lobby(lobby, second_user)
+ set_ready(lobby, test_user, True)
+ set_ready(lobby, second_user, True)
+ Card.objects.filter(special_card__isnull=True).delete()
+ assert Card.objects.filter(special_card__isnull=True).count() == 0
+ game = start_game(lobby, test_user)
+ assert game.status == "in_progress"
+ assert Card.objects.filter(special_card__isnull=True).count() >= 36
+
+
+@pytest.mark.django_db
+def test_active_game_blocks_second_start(test_user, second_user, durak_deck_36):
+ """Starting again while a game is in progress must raise GameError."""
+ lobby = create_lobby(test_user, "G3", is_private=False)
+ join_lobby(lobby, second_user)
+ set_ready(lobby, test_user, True)
+ set_ready(lobby, second_user, True)
+ start_game(lobby, test_user)
+ # Lobby still 'playing' but can_start_game is False — service checks active Game.
+ with pytest.raises(GameError) as exc:
+ start_game(lobby, test_user)
+ assert exc.value.code == "state"
+
+
+@pytest.mark.django_db
+def test_attack_stays_in_build_for_parallel_defense(test_user, second_user, durak_deck_36):
+ """Wave stays in ``build`` so the defender can beat while others still throw in."""
+ lobby = create_lobby(test_user, "parallel-wave", is_private=False)
+ join_lobby(lobby, second_user)
+ set_ready(lobby, test_user, True)
+ set_ready(lobby, second_user, True)
+ game = start_game(lobby, test_user)
+ rs = game.runtime_state or {}
+ assert rs.get("phase") == "between"
+ attacker = test_user if str(test_user.id) == rs.get("attacker_id") else second_user
+ hands = PlayerHand.objects.filter(game=game, player=attacker).select_related("card__rank")
+ by_rank_value = {}
+ for ph in hands:
+ by_rank_value.setdefault(ph.card.rank.value, []).append(ph.card.id)
+ chosen = max(by_rank_value, key=lambda v: len(by_rank_value[v]))
+ card_ids = by_rank_value[chosen]
+ play_attack(game, attacker, card_ids)
+ game.refresh_from_db()
+ assert (game.runtime_state or {}).get("phase") == PHASE_BUILD
+
+
+@pytest.mark.django_db
+def test_table_attack_ranks_includes_defensive_card(durak_deck_36, test_user):
+ """Podkidnoy: ranks on covered cards (defense) count for matching throw-ins."""
+ lobby = Lobby.objects.create(owner=test_user, name="throw-ranks", status="playing")
+ LobbySettings.objects.create(lobby=lobby, max_players=4, card_count=36)
+ trump = Card.objects.filter(special_card__isnull=True).first()
+ game = Game.objects.create(lobby=lobby, trump_card=trump, status="in_progress", runtime_state={})
+ eight = Card.objects.filter(rank__value=8, special_card__isnull=True).first()
+ q_cover = Card.objects.filter(rank__value=12, special_card__isnull=True).first()
+ TableCard.objects.create(game=game, attack_card=eight, defense_card=q_cover)
+ allowed = _table_attack_ranks(game)
+ assert 8 in allowed
+ assert 12 in allowed
+
+
+@pytest.mark.django_db
+def test_third_player_may_throw_rank_seen_only_on_defense(durak_deck_36, user_factory):
+ """Third player can throw a queen when the only queen on table is the defender's card."""
+ a = user_factory(username="pod_a")
+ b = user_factory(username="pod_b")
+ c = user_factory(username="pod_c")
+ lobby = Lobby.objects.create(owner=a, name="podkidnut", status="playing")
+ LobbySettings.objects.create(lobby=lobby, max_players=4, card_count=36)
+ trump = Card.objects.filter(special_card__isnull=True).first()
+ game = Game.objects.create(
+ lobby=lobby,
+ trump_card=trump,
+ status="in_progress",
+ runtime_state={
+ "phase": PHASE_BUILD,
+ "attacker_id": str(a.id),
+ "defender_id": str(b.id),
+ },
+ )
+ for seat, u in enumerate((a, b, c), start=1):
+ GamePlayer.objects.create(game=game, user=u, seat_position=seat, cards_remaining=0)
+ eight = Card.objects.filter(rank__value=8, special_card__isnull=True).first()
+ queens = list(Card.objects.filter(rank__value=12, special_card__isnull=True)[:2])
+ q_cover, q_hand = queens[0], queens[1]
+ blocked = {eight.id, q_cover.id, q_hand.id}
+ filler = list(Card.objects.filter(special_card__isnull=True).exclude(id__in=blocked)[:6])
+ for i, card in enumerate(filler):
+ PlayerHand.objects.create(game=game, player=b, card=card, order_in_hand=i + 1)
+ GamePlayer.objects.filter(game=game, user=b).update(cards_remaining=len(filler))
+ TableCard.objects.create(game=game, attack_card=eight, defense_card=q_cover)
+ PlayerHand.objects.create(game=game, player=c, card=q_hand, order_in_hand=1)
+ GamePlayer.objects.filter(game=game, user=c).update(cards_remaining=1)
+
+ play_attack(game, c, [q_hand.id])
+ assert TableCard.objects.filter(game=game).count() == 2
+
+
+@pytest.mark.django_db
+def test_throw_eight_when_two_tens_beaten_by_eight_and_queen(durak_deck_36, user_factory):
+ """Ranks from all defense cards count with multiple table rows (two attacks)."""
+ a = user_factory(username="mix_a")
+ b = user_factory(username="mix_b")
+ c = user_factory(username="mix_c")
+ lobby = Lobby.objects.create(owner=a, name="mix-throw", status="playing")
+ LobbySettings.objects.create(lobby=lobby, max_players=4, card_count=36)
+ trump = Card.objects.filter(special_card__isnull=True).first()
+ game = Game.objects.create(
+ lobby=lobby,
+ trump_card=trump,
+ status="in_progress",
+ runtime_state={
+ "phase": PHASE_BUILD,
+ "attacker_id": str(a.id),
+ "defender_id": str(b.id),
+ },
+ )
+ for seat, u in enumerate((a, b, c), start=1):
+ GamePlayer.objects.create(game=game, user=u, seat_position=seat, cards_remaining=0)
+ tens = list(Card.objects.filter(rank__value=10, special_card__isnull=True)[:2])
+ eights = list(Card.objects.filter(rank__value=8, special_card__isnull=True)[:2])
+ queens = list(Card.objects.filter(rank__value=12, special_card__isnull=True)[:1])
+ blocked = {tens[0].id, tens[1].id, eights[0].id, eights[1].id, queens[0].id}
+ filler = list(Card.objects.filter(special_card__isnull=True).exclude(id__in=blocked)[:6])
+ for i, card in enumerate(filler):
+ PlayerHand.objects.create(game=game, player=b, card=card, order_in_hand=i + 1)
+ GamePlayer.objects.filter(game=game, user=b).update(cards_remaining=len(filler))
+ TableCard.objects.create(game=game, attack_card=tens[0], defense_card=eights[0])
+ TableCard.objects.create(game=game, attack_card=tens[1], defense_card=queens[0])
+ PlayerHand.objects.create(game=game, player=c, card=eights[1], order_in_hand=1)
+ GamePlayer.objects.filter(game=game, user=c).update(cards_remaining=1)
+
+ assert 8 in _table_attack_ranks(game)
+ play_attack(game, c, [eights[1].id])
+ assert TableCard.objects.filter(game=game).count() == 3
diff --git a/game/urls.py b/game/urls.py
new file mode 100644
index 0000000..b0cf7f1
--- /dev/null
+++ b/game/urls.py
@@ -0,0 +1,9 @@
+from django.urls import path
+
+from game import views
+
+urlpatterns = [
+ path("", views.lobby_list_page, name="game-lobby-list-page"),
+ path("lobbies//", views.lobby_detail_page, name="game-lobby-detail-page"),
+ path("play//", views.game_play_page, name="game-play-page"),
+]
diff --git a/game/views.py b/game/views.py
index 91ea44a..90777a4 100644
--- a/game/views.py
+++ b/game/views.py
@@ -1,3 +1,38 @@
+"""Thin template views: pages load all state via ``/api/game/`` from JavaScript."""
+
+from django.contrib.auth.decorators import login_required
from django.shortcuts import render
-# Create your views here.
+
+@login_required
+def lobby_list_page(request):
+ """Render the public lobby browser shell (data via fetch)."""
+ return render(request, "game/lobby_list.html")
+
+
+@login_required
+def lobby_detail_page(request, lobby_id):
+ """Render a single lobby + chat shell; ``lobby_id`` is only in the URL path.
+
+ Args:
+ request: Django request (must be authenticated).
+ lobby_id: UUID from the route; the template reads the same id client-side.
+
+ Returns:
+ HttpResponse: ``lobby_detail.html`` without server-side lobby context.
+ """
+ return render(request, "game/lobby_detail.html")
+
+
+@login_required
+def game_play_page(request, game_id):
+ """Render the table UI shell for a running ``Game`` id.
+
+ Args:
+ request: Authenticated request.
+ game_id: UUID string from the URL.
+
+ Returns:
+ HttpResponse: ``play.html`` with no game payload embedded server-side.
+ """
+ return render(request, "game/play.html")
diff --git a/pytest.ini b/pytest.ini
index 8f0445d..a18c241 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,3 +1,3 @@
[pytest]
-DJANGO_SETTINGS_MODULE = Fools_Arena.settings
+DJANGO_SETTINGS_MODULE = Fools_Arena.test_settings
python_files = tests.py test_*.py *_tests.py
diff --git a/requirements.txt b/requirements.txt
index 76a5d9a..bd58b66 100644
Binary files a/requirements.txt and b/requirements.txt differ
diff --git a/schema.png b/schema.png
new file mode 100644
index 0000000..61f5eeb
Binary files /dev/null and b/schema.png differ