"""Game models for Mantimon TCG. This module defines models for game state management: - ActiveGame: In-progress games (Postgres backup for Redis) - GameHistory: Completed games with replay data Example: # Creating an active game game = ActiveGame( game_type=GameType.CAMPAIGN, player1_id=user.id, npc_id="grass_trainer_1", rules_config={"prize_count": 4}, game_state=serialized_state ) # Recording game history history = GameHistory( game_type=GameType.CAMPAIGN, player1_id=user.id, npc_id="grass_trainer_1", winner_id=user.id, end_reason=EndReason.PRIZES_TAKEN, turn_count=15 ) """ from datetime import UTC, datetime from enum import Enum from typing import TYPE_CHECKING from uuid import UUID from sqlalchemy import DateTime, ForeignKey, Index, Integer, String from sqlalchemy import Enum as SQLEnum 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 GameType(str, Enum): """Type of game being played. Attributes: CAMPAIGN: Single-player campaign match vs NPC. FREEPLAY: Casual unranked match. RANKED: Competitive ranked match. PRACTICE: Practice mode (no rewards). """ CAMPAIGN = "campaign" FREEPLAY = "freeplay" RANKED = "ranked" PRACTICE = "practice" class EndReason(str, Enum): """Reason a game ended. Attributes: PRIZES_TAKEN: Winner took all prize cards. NO_POKEMON: Opponent had no Pokemon left in play. CANNOT_DRAW: Opponent couldn't draw at turn start. RESIGNATION: Player resigned/quit. TIMEOUT: Player timed out. TURN_LIMIT: Game reached turn limit (draw possible). DISCONNECTION: Player disconnected and didn't reconnect. DRAW: Game ended in a draw. """ PRIZES_TAKEN = "prizes_taken" NO_POKEMON = "no_pokemon" CANNOT_DRAW = "cannot_draw" RESIGNATION = "resignation" TIMEOUT = "timeout" TURN_LIMIT = "turn_limit" DISCONNECTION = "disconnection" DRAW = "draw" class ActiveGame(Base): """In-progress game state (Postgres backup). This is the durable storage for active games. The primary game state lives in Redis for fast access during gameplay. Postgres serves as backup and is updated at turn boundaries. Attributes: id: Unique game identifier (UUID). game_type: Type of game (campaign, freeplay, ranked). player1_id: First player's user ID. player2_id: Second player's user ID (null for campaign). npc_id: NPC opponent ID for campaign games. rules_config: JSONB of RulesConfig settings. game_state: JSONB of serialized GameState. turn_number: Current turn number. started_at: When the game started. last_action_at: Timestamp of last action. turn_deadline: When current turn times out. Notes: - Redis is primary, Postgres is backup - Updated at turn boundaries (not every action) - On server restart, active games load from Postgres -> Redis """ __tablename__ = "active_games" # Game type game_type: Mapped[GameType] = mapped_column( SQLEnum(GameType, name="game_type", create_constraint=True), nullable=False, doc="Type of game (campaign, freeplay, ranked)", ) # Players player1_id: Mapped[UUID] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), nullable=False, doc="First player's user ID", ) player2_id: Mapped[UUID | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True, doc="Second player's user ID (null for campaign)", ) npc_id: Mapped[str | None] = mapped_column( String(100), nullable=True, doc="NPC opponent ID for campaign games", ) # Game configuration and state (JSONB) rules_config: Mapped[dict] = mapped_column( JSONB, default=dict, nullable=False, doc="RulesConfig settings as JSONB", ) game_state: Mapped[dict] = mapped_column( JSONB, default=dict, nullable=False, doc="Serialized GameState as JSONB", ) turn_number: Mapped[int] = mapped_column( Integer, default=0, nullable=False, doc="Current turn number", ) # Timing started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False, doc="When the game started", ) last_action_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False, doc="Timestamp of last action", ) turn_deadline: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, doc="When current turn times out", ) # Relationships player1: Mapped["User"] = relationship( "User", foreign_keys=[player1_id], lazy="selectin", ) player2: Mapped["User | None"] = relationship( "User", foreign_keys=[player2_id], lazy="selectin", ) # Indexes __table_args__ = ( Index("ix_active_games_player1", "player1_id"), Index("ix_active_games_player2", "player2_id"), Index("ix_active_games_players", "player1_id", "player2_id"), ) def __repr__(self) -> str: return f"" class GameHistory(Base): """Completed game record. Stores the result of finished games including replay data for future replay feature. Attributes: id: Unique identifier (UUID). game_type: Type of game that was played. player1_id: First player's user ID. player2_id: Second player's user ID (null for campaign). npc_id: NPC opponent ID for campaign games. winner_id: User ID of the winner (null for draws). winner_is_npc: True if NPC won (campaign games). end_reason: How the game ended. turn_count: Total turns played. duration_seconds: Game duration in seconds. replay_data: JSONB containing action log for replays. played_at: When the game was completed. Notes: - Replay data captures all actions from game start - Indexes optimize leaderboard and history queries """ __tablename__ = "game_history" # Game type game_type: Mapped[GameType] = mapped_column( SQLEnum(GameType, name="game_type", create_constraint=True), nullable=False, doc="Type of game that was played", ) # Players # NOTE: nullable=True to allow SET NULL on cascade when user is deleted. # Game history is preserved even after user deletion for leaderboards. player1_id: Mapped[UUID | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True, doc="First player's user ID (nullable for preserved history)", ) player2_id: Mapped[UUID | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True, doc="Second player's user ID", ) npc_id: Mapped[str | None] = mapped_column( String(100), nullable=True, doc="NPC opponent ID for campaign games", ) # Result winner_id: Mapped[UUID | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True, doc="Winner's user ID (null for draws or NPC wins)", ) winner_is_npc: Mapped[bool] = mapped_column( default=False, nullable=False, doc="True if NPC won (campaign games)", ) end_reason: Mapped[EndReason] = mapped_column( SQLEnum(EndReason, name="end_reason", create_constraint=True), nullable=False, doc="How the game ended", ) # Statistics turn_count: Mapped[int] = mapped_column( Integer, nullable=False, doc="Total turns played", ) duration_seconds: Mapped[int] = mapped_column( Integer, nullable=False, doc="Game duration in seconds", ) # Replay data (captures all actions) replay_data: Mapped[dict | None] = mapped_column( JSONB, nullable=True, doc="Action log for replay feature", ) # Timestamp played_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False, doc="When the game was completed", ) # Relationships # NOTE: passive_deletes=True lets database handle ON DELETE SET NULL player1: Mapped["User | None"] = relationship( "User", foreign_keys=[player1_id], lazy="selectin", passive_deletes=True, ) player2: Mapped["User | None"] = relationship( "User", foreign_keys=[player2_id], lazy="selectin", passive_deletes=True, ) winner: Mapped["User | None"] = relationship( "User", foreign_keys=[winner_id], lazy="selectin", passive_deletes=True, ) # Indexes for leaderboard and history queries __table_args__ = ( Index("ix_game_history_player1", "player1_id"), Index("ix_game_history_player2", "player2_id"), Index("ix_game_history_winner", "winner_id"), Index("ix_game_history_played_at", "played_at"), Index("ix_game_history_type_played", "game_type", "played_at"), ) @property def is_draw(self) -> bool: """Check if game ended in a draw. Returns: True if no winner and end reason is DRAW. """ return self.end_reason == EndReason.DRAW def __repr__(self) -> str: return ( f"" )