mantimon-tcg/backend/app/db/models/campaign.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

186 lines
5.1 KiB
Python

"""CampaignProgress model for Mantimon TCG.
This module defines the CampaignProgress model for single-player campaign state.
Tracks player progression through clubs, medals, and in-game economy.
Example:
progress = CampaignProgress(
user_id=user.id,
current_club="grass_club",
medals=["grass_medal"],
defeated_npcs=["grass_trainer_1", "grass_trainer_2"],
mantibucks=500
)
"""
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
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 CampaignProgress(Base):
"""Single-player campaign progress.
Stores all state for a user's campaign playthrough including:
- Current location in the campaign
- Medals earned from defeating Club Leaders
- NPCs defeated (for reward tracking)
- Win/loss statistics
- In-game economy (booster packs, Mantibucks)
Attributes:
id: Unique identifier (UUID).
user_id: Foreign key to the user (unique - one campaign per user).
current_club: ID of the club player is currently at.
medals: JSONB list of earned medal IDs.
defeated_npcs: JSONB list of defeated NPC IDs.
total_wins: Total campaign wins.
total_losses: Total campaign losses.
booster_packs: Number of unopened booster packs.
mantibucks: In-game currency balance.
created_at: Record creation timestamp.
updated_at: Record update timestamp.
Relationships:
user: The user this progress belongs to.
Notes:
- Each user has exactly one CampaignProgress
- Defeating NPCs grants booster packs (first time only)
- Mantibucks can be used for card purchases
- 8 medals required to face Grand Masters
"""
__tablename__ = "campaign_progress"
# Ownership (one-to-one with User)
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
unique=True,
doc="Foreign key to user (unique - one campaign per user)",
)
# Current location
current_club: Mapped[str] = mapped_column(
String(50),
default="grass_club",
nullable=False,
doc="ID of current club location",
)
# Progress tracking (JSONB for flexible lists)
medals: Mapped[list] = mapped_column(
JSONB,
default=list,
nullable=False,
doc="List of earned medal IDs",
)
defeated_npcs: Mapped[list] = mapped_column(
JSONB,
default=list,
nullable=False,
doc="List of defeated NPC IDs (for reward tracking)",
)
# Statistics
total_wins: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
doc="Total campaign wins",
)
total_losses: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
doc="Total campaign losses",
)
# Economy
booster_packs: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
doc="Number of unopened booster packs",
)
mantibucks: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
doc="In-game currency balance",
)
# Relationship
user: Mapped["User"] = relationship(
"User",
back_populates="campaign_progress",
)
# Constraints
__table_args__ = (UniqueConstraint("user_id", name="uq_campaign_user"),)
@property
def medal_count(self) -> int:
"""Get number of medals earned.
Returns:
Number of medals in collection.
"""
return len(self.medals) if self.medals else 0
@property
def can_face_grand_masters(self) -> bool:
"""Check if player has enough medals for Grand Masters.
Returns:
True if player has 8+ medals.
"""
return self.medal_count >= 8
@property
def win_rate(self) -> float:
"""Calculate win rate percentage.
Returns:
Win rate as decimal (0.0 to 1.0), or 0 if no games played.
"""
total = self.total_wins + self.total_losses
return self.total_wins / total if total > 0 else 0.0
def has_defeated(self, npc_id: str) -> bool:
"""Check if an NPC has been defeated.
Args:
npc_id: ID of the NPC to check.
Returns:
True if NPC is in defeated list.
"""
return npc_id in (self.defeated_npcs or [])
def has_medal(self, medal_id: str) -> bool:
"""Check if a medal has been earned.
Args:
medal_id: ID of the medal to check.
Returns:
True if medal is in collection.
"""
return medal_id in (self.medals or [])
def __repr__(self) -> str:
return (
f"<CampaignProgress(user_id={self.user_id!r}, "
f"medals={self.medal_count}, club={self.current_club!r})>"
)