"""Base types for the effect handler system. This module defines the core types used by all effect handlers: - EffectContext: The context object passed to every effect handler - EffectResult: The result returned by effect handlers - EffectType: Categorization of effects for UI/logging EffectContext provides a clean interface for effect handlers to interact with the game state without needing to know the internal structure. It includes helper methods for common operations like finding cards and getting definitions. Usage: @effect_handler("deal_damage") def handle_deal_damage(ctx: EffectContext) -> EffectResult: target = ctx.get_target_pokemon() if target is None: return EffectResult.failure("No target specified") amount = ctx.params.get("amount", 0) target.damage += amount return EffectResult.success(f"Dealt {amount} damage") """ from enum import StrEnum from typing import TYPE_CHECKING, Any from pydantic import BaseModel, Field if TYPE_CHECKING: from app.core.models.card import CardDefinition, CardInstance from app.core.models.game_state import GameState, PlayerState from app.core.rng import RandomProvider class EffectType(StrEnum): """Categories of effects for logging and UI purposes. These help the game engine and UI understand what kind of effect occurred without parsing the effect_id. """ DAMAGE = "damage" # Deals damage to a Pokemon HEAL = "heal" # Heals damage from a Pokemon DRAW = "draw" # Draws cards from deck DISCARD = "discard" # Discards cards SEARCH = "search" # Searches deck for cards SHUFFLE = "shuffle" # Shuffles deck STATUS = "status" # Applies or removes status conditions ENERGY = "energy" # Attaches, moves, or discards energy BENCH = "bench" # Affects bench (swap, damage, etc.) MODIFIER = "modifier" # Modifies stats (HP, damage, costs) COIN_FLIP = "coin_flip" # Coin flip with conditional effect SPECIAL = "special" # Unique effects that don't fit other categories class EffectResult(BaseModel): """Result returned by an effect handler. Effect handlers return this to indicate success/failure and provide information for logging, UI updates, and debugging. Attributes: success: Whether the effect was successfully applied. message: Human-readable description of what happened. effect_type: Category of the effect for UI/logging. details: Additional structured data about the effect (for logging/replay). state_changes: List of state changes made (for undo/replay support). """ success: bool message: str effect_type: EffectType | None = None details: dict[str, Any] = Field(default_factory=dict) state_changes: list[dict[str, Any]] = Field(default_factory=list) @classmethod def success_result( cls, message: str, effect_type: EffectType | None = None, details: dict[str, Any] | None = None, ) -> "EffectResult": """Create a successful result. Args: message: Description of what happened. effect_type: Category of the effect. details: Additional structured data. Returns: An EffectResult with success=True. """ return cls( success=True, message=message, effect_type=effect_type, details=details or {}, ) @classmethod def failure(cls, message: str) -> "EffectResult": """Create a failure result. Args: message: Description of why the effect failed. Returns: An EffectResult with success=False. """ return cls(success=False, message=message) class EffectContext: """Context object passed to effect handlers. Provides access to the game state and helper methods for common operations. Effect handlers should use this rather than directly manipulating game state when possible, as it provides a cleaner interface and enables logging. The context is created fresh for each effect execution and is not persisted. Attributes: game: The current game state (mutable - handlers modify this). source_player_id: ID of the player who triggered the effect. source_card_id: Instance ID of the card that triggered the effect (if any). target_player_id: ID of the target player (if applicable). target_card_id: Instance ID of the target card (if applicable). params: Effect-specific parameters from the card/attack definition. rng: Random number generator for coin flips, etc. Example: ctx = EffectContext( game=game_state, source_player_id="player1", source_card_id="pikachu-uuid", target_card_id="charmander-uuid", params={"amount": 30}, rng=rng, ) result = deal_damage_handler(ctx) """ def __init__( self, game: "GameState", source_player_id: str, rng: "RandomProvider", source_card_id: str | None = None, target_player_id: str | None = None, target_card_id: str | None = None, params: dict[str, Any] | None = None, ) -> None: """Initialize an effect context. Args: game: The current game state. source_player_id: ID of the player triggering the effect. rng: Random number generator for this effect. source_card_id: Instance ID of the source card (optional). target_player_id: ID of the target player (optional). target_card_id: Instance ID of the target card (optional). params: Effect-specific parameters (optional). """ self.game = game self.source_player_id = source_player_id self.source_card_id = source_card_id self.target_player_id = target_player_id self.target_card_id = target_card_id self.params = params or {} self.rng = rng # ========================================================================= # Player Access # ========================================================================= def get_source_player(self) -> "PlayerState": """Get the player who triggered this effect. Returns: The source player's state. Raises: KeyError: If source_player_id is not in the game. """ return self.game.players[self.source_player_id] def get_target_player(self) -> "PlayerState | None": """Get the target player, if specified. Returns: The target player's state, or None if no target specified. """ if self.target_player_id is None: return None return self.game.players.get(self.target_player_id) def get_opponent(self) -> "PlayerState": """Get the opponent of the source player. Convenience method for 2-player games. Returns: The opponent's player state. """ return self.game.get_opponent(self.source_player_id) def get_opponent_id(self) -> str: """Get the opponent's player ID. Returns: The opponent's player ID. """ return self.game.get_opponent_id(self.source_player_id) # ========================================================================= # Card Access # ========================================================================= def get_source_card(self) -> "CardInstance | None": """Get the card that triggered this effect. Returns: The source CardInstance, or None if no source card. """ if self.source_card_id is None: return None card, _ = self.game.find_card_instance(self.source_card_id) return card def get_target_card(self) -> "CardInstance | None": """Get the target card, if specified. Returns: The target CardInstance, or None if no target specified. """ if self.target_card_id is None: return None card, _ = self.game.find_card_instance(self.target_card_id) return card def get_source_pokemon(self) -> "CardInstance | None": """Get the source card if it's the active Pokemon. Convenience method for attack effects where the source is the attacker. Returns: The source player's active Pokemon, or None. """ return self.get_source_player().get_active_pokemon() def get_target_pokemon(self) -> "CardInstance | None": """Get the target Pokemon. If target_card_id is specified, returns that card. Otherwise, returns the opponent's active Pokemon (common default). Returns: The target CardInstance, or None if not found. """ if self.target_card_id: return self.get_target_card() # Default to opponent's active opponent = self.get_opponent() return opponent.get_active_pokemon() def get_card_definition(self, card: "CardInstance") -> "CardDefinition | None": """Look up the definition for a card instance. Args: card: The CardInstance to look up. Returns: The CardDefinition, or None if not found in registry. """ return self.game.get_card_definition(card.definition_id) def find_card(self, instance_id: str) -> tuple["CardInstance | None", str | None]: """Find a card anywhere in the game. Args: instance_id: The instance ID to search for. Returns: Tuple of (CardInstance, zone_name) or (None, None) if not found. """ return self.game.find_card_instance(instance_id) # ========================================================================= # Parameter Access # ========================================================================= def get_param(self, key: str, default: Any = None) -> Any: """Get an effect parameter with optional default. Args: key: The parameter key. default: Default value if key not present. Returns: The parameter value or default. """ return self.params.get(key, default) def get_int_param(self, key: str, default: int = 0) -> int: """Get an integer parameter. Args: key: The parameter key. default: Default value if key not present or not an int. Returns: The parameter as an integer. """ value = self.params.get(key, default) if isinstance(value, int): return value try: return int(value) except (TypeError, ValueError): return default def get_str_param(self, key: str, default: str = "") -> str: """Get a string parameter. Args: key: The parameter key. default: Default value if key not present. Returns: The parameter as a string. """ value = self.params.get(key, default) return str(value) if value is not None else default # ========================================================================= # Random Operations # ========================================================================= def flip_coin(self) -> bool: """Flip a coin. Returns: True for heads, False for tails. """ return self.rng.coin_flip() def flip_coins(self, count: int) -> list[bool]: """Flip multiple coins. Args: count: Number of coins to flip. Returns: List of results (True=heads, False=tails). """ return [self.rng.coin_flip() for _ in range(count)] def count_heads(self, count: int) -> int: """Flip coins and count heads. Args: count: Number of coins to flip. Returns: Number of heads. """ return sum(self.flip_coins(count)) # ========================================================================= # Rules Access # ========================================================================= @property def rules(self): """Get the game's rules configuration. Returns: The RulesConfig for this game. """ return self.game.rules @property def is_first_turn(self) -> bool: """Check if this is the first turn of the game. Returns: True if this is the first turn. """ return self.game.is_first_turn()