mantimon-tcg/backend/app/db/models/deck.py
Cal Corum 50684a1b11 Add database infrastructure with SQLAlchemy models and test suite
Phase 1 Database Implementation (DB-001 through DB-012):

Models:
- User: OAuth support (Google/Discord), premium subscriptions
- Collection: Card ownership with CardSource enum
- Deck: JSONB cards/energy_cards, validation state
- CampaignProgress: One-to-one with User, medals/NPCs as JSONB
- ActiveGame: In-progress games with GameType enum
- GameHistory: Completed games with EndReason enum, replay data

Infrastructure:
- Alembic migrations with sync psycopg2 (avoids async issues)
- Docker Compose for Postgres (5433) and Redis (6380)
- App config with Pydantic settings
- Redis client helper

Test Infrastructure:
- 68 database tests (47 model + 21 relationship)
- Async factory pattern for test data creation
- Sync TRUNCATE cleanup (solves pytest-asyncio event loop mismatch)
- Uses dev containers instead of testcontainers for reliability

Key technical decisions:
- passive_deletes=True for ON DELETE SET NULL relationships
- NullPool for test sessions (no connection reuse)
- expire_on_commit=False with manual expire() for relationship tests
2026-01-27 10:17:30 -06:00

163 lines
4.5 KiB
Python

"""Deck model for Mantimon TCG.
This module defines the Deck model for player deck configurations.
Decks store card lists as JSONB for flexibility and can be validated
against campaign rules or used freely in freeplay mode.
Example:
deck = Deck(
user_id=user.id,
name="Electric Storm",
cards={"pikachu_base_001": 2, "raichu_base_001": 1, ...},
energy_cards={"lightning": 10, "colorless": 4}
)
"""
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import Boolean, ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
if TYPE_CHECKING:
from app.db.models.user import User
class Deck(Base):
"""Player deck configuration.
Stores a deck's card composition and validation state.
Cards are stored as JSONB mapping card_definition_id -> quantity.
Attributes:
id: Unique identifier (UUID).
user_id: Foreign key to the owning user.
name: Display name for the deck.
cards: JSONB mapping card IDs to quantities.
energy_cards: JSONB mapping energy types to quantities.
is_valid: Whether deck passes validation rules.
validation_errors: JSONB list of validation error messages.
is_starter: Whether this is a pre-built starter deck.
starter_type: Type of starter deck (grass, fire, water, etc.).
created_at: Record creation timestamp.
updated_at: Record update timestamp.
Relationships:
user: The user who owns this deck.
Notes:
- Campaign mode requires deck ownership validation
- Freeplay mode unlocks all cards (validation skipped)
- Free users limited to 5 decks, premium unlimited
"""
__tablename__ = "decks"
# Ownership
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
doc="Foreign key to owning user",
)
# Deck identity
name: Mapped[str] = mapped_column(
String(100),
nullable=False,
doc="Display name for the deck",
)
# Card composition (JSONB for flexibility)
# Format: {"card_definition_id": quantity, ...}
cards: Mapped[dict] = mapped_column(
JSONB,
default=dict,
nullable=False,
doc="Mapping of card IDs to quantities",
)
# Energy cards tracked separately for easier UI display
# Format: {"lightning": 10, "fire": 5, ...}
energy_cards: Mapped[dict] = mapped_column(
JSONB,
default=dict,
nullable=False,
doc="Mapping of energy types to quantities",
)
# Validation state
is_valid: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
doc="Whether deck passes validation rules",
)
validation_errors: Mapped[list | None] = mapped_column(
JSONB,
nullable=True,
doc="List of validation error messages",
)
# Starter deck flags
is_starter: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
doc="Whether this is a pre-built starter deck",
)
starter_type: Mapped[str | None] = mapped_column(
String(20),
nullable=True,
doc="Type of starter deck (grass, fire, water, etc.)",
)
# Optional notes/description
description: Mapped[str | None] = mapped_column(
Text,
nullable=True,
doc="Optional deck description or notes",
)
# Relationship
user: Mapped["User"] = relationship(
"User",
back_populates="decks",
)
# Indexes
__table_args__ = (Index("ix_decks_user_name", "user_id", "name"),)
@property
def total_cards(self) -> int:
"""Get total number of cards in deck (excluding energy).
Returns:
Sum of all card quantities.
"""
return sum(self.cards.values()) if self.cards else 0
@property
def total_energy(self) -> int:
"""Get total number of energy cards.
Returns:
Sum of all energy card quantities.
"""
return sum(self.energy_cards.values()) if self.energy_cards else 0
@property
def deck_size(self) -> int:
"""Get total deck size (cards + energy).
Returns:
Total cards in deck.
"""
return self.total_cards + self.total_energy
def __repr__(self) -> str:
return f"<Deck(id={self.id!r}, name={self.name!r}, size={self.deck_size})>"