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
186 lines
5.1 KiB
Python
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})>"
|
|
)
|