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
163 lines
4.5 KiB
Python
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})>"
|