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 %} - +

Fools Arena

@@ -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

+

+    
    +
    +
    + + + + +
    +

    +
    + +
    +

    Lobby chat

    +
    +
    + + +
    +
    +
    + +{% 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…

    +
      +
      + +
      +

      Create lobby

      +
      + + + + + + + +
      + +
      +
      +

      +
      +
      + +{% 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 %} +
      +
      + ← Back to lobby +
      +
      + +
      +
      + +
      +
      +
      + +
      +
      +
      + + +
      +
      + +
      +
      +

      +
      +

      Click a card to toggle selecting cards of the same rank. Drag to the felt to attack or add cards; drag onto an uncovered attack card to defend.

      +
      +
      + +

      +
      + +{% 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