"""Card models for the Mantimon TCG game engine. This module defines two key model types: - CardDefinition: Immutable template for a card (the "blueprint") - CardInstance: Mutable in-game state for a card in play The separation allows efficient memory usage (definitions are shared) while maintaining per-card state during gameplay (damage, attached energy, etc.). Usage: # Define a card template pikachu = CardDefinition( id="pikachu_base_001", name="Pikachu", card_type=CardType.POKEMON, stage=PokemonStage.BASIC, hp=60, pokemon_type=EnergyType.LIGHTNING, attacks=[Attack(name="Thunder Shock", cost=[EnergyType.LIGHTNING], damage=20)], ) # Create an instance for gameplay instance = CardInstance( instance_id="uuid-here", definition_id="pikachu_base_001", ) # Track damage during battle instance.damage = 30 """ from typing import Any from pydantic import BaseModel, Field, model_validator from app.core.enums import ( CardType, EnergyType, ModifierMode, PokemonStage, PokemonVariant, StatusCondition, TrainerType, ) class Attack(BaseModel): """An attack that a Pokemon can use. Attacks have an energy cost, base damage, and optional effect. Effects are referenced by ID and resolved by the effect handler system. Attributes: name: Display name of the attack. cost: List of energy types required to use this attack. Colorless can be satisfied by any energy type. damage: Base damage dealt by this attack. May be modified by effects. damage_display: UI display string for variable damage (e.g., "30+", "50x"). Used when damage can vary based on conditions. If None, just show damage value. effect_id: Optional reference to an effect handler for special effects. effect_params: Parameters passed to the effect handler. effect_description: Human-readable description of the effect. """ name: str cost: list[EnergyType] = Field(default_factory=list) damage: int = 0 damage_display: str | None = None effect_id: str | None = None effect_params: dict[str, Any] = Field(default_factory=dict) effect_description: str | None = None class Ability(BaseModel): """A special ability that a Pokemon has. Abilities can be activated during the main phase (unless specified otherwise). Like attacks, abilities reference effect handlers by ID. Attributes: name: Display name of the ability. effect_id: Reference to an effect handler. effect_params: Parameters passed to the effect handler. effect_description: Human-readable description of the ability. uses_per_turn: Maximum uses per turn. None means unlimited uses. Default is 1 (once per turn), which matches standard Pokemon TCG rules. """ name: str effect_id: str effect_params: dict[str, Any] = Field(default_factory=dict) effect_description: str | None = None uses_per_turn: int | None = 1 # None = unlimited, 1 = once per turn (default) class WeaknessResistance(BaseModel): """Weakness or resistance to a specific energy type. This model supports both the global defaults from RulesConfig.combat and per-card overrides. If mode/value are not specified, the handler will use the game's CombatConfig defaults. Attributes: energy_type: The energy type this applies to. mode: How to apply the modifier (multiplicative or additive). If None, uses the game's CombatConfig default for this modifier type. value: The modifier value to apply. - For multiplicative: damage * value (e.g., 2 for x2) - For additive: damage + value (e.g., +20 or -30) If None, uses the game's CombatConfig default. Examples: # Use game defaults (mode and value from CombatConfig) WeaknessResistance(energy_type=EnergyType.FIRE) # Override just the value (still uses default mode) WeaknessResistance(energy_type=EnergyType.FIRE, value=3) # x3 if default is multiplicative # Full override (card-specific behavior) WeaknessResistance( energy_type=EnergyType.FIRE, mode=ModifierMode.ADDITIVE, value=40, # +40 damage instead of x2 ) Legacy Support: The old 'modifier' field is deprecated but still works for backwards compatibility. If 'modifier' is provided without 'value', it will be used as the value. The mode will still default to CombatConfig. """ energy_type: EnergyType mode: ModifierMode | None = None value: int | None = None # Legacy field for backwards compatibility modifier: int | None = None def get_value(self, default: int) -> int: """Get the modifier value, falling back to default if not specified. Args: default: The default value from CombatConfig. Returns: The value to use for this modifier. """ if self.value is not None: return self.value if self.modifier is not None: return self.modifier return default def get_mode(self, default: ModifierMode) -> ModifierMode: """Get the modifier mode, falling back to default if not specified. Args: default: The default mode from CombatConfig. Returns: The mode to use for this modifier. """ return self.mode if self.mode is not None else default class CardDefinition(BaseModel): """Immutable template for a card. This represents the "blueprint" of a card - all the static information that doesn't change during gameplay. Multiple CardInstances can reference the same CardDefinition. The card_type field determines which other fields are relevant: - POKEMON: stage, variant, hp, pokemon_type, attacks, abilities, weakness, resistance, retreat_cost - TRAINER: trainer_type, effect_id, effect_params - ENERGY: energy_type, energy_provides Attributes: id: Unique identifier for this card definition (e.g., "pikachu_base_001"). name: Display name of the card. card_type: Whether this is a Pokemon, Trainer, or Energy card. stage: Evolution stage - BASIC, STAGE_1, or STAGE_2 (Pokemon only). variant: Special variant - NORMAL, EX, GX, V, VMAX, VSTAR (Pokemon only). Affects knockout points but not evolution mechanics. evolves_from: Name of the Pokemon this evolves from (Stage 1/2, VMAX, VSTAR only). hp: Hit points (Pokemon only). pokemon_type: Energy type of this Pokemon (for weakness/resistance). attacks: List of attacks this Pokemon can use. abilities: List of abilities this Pokemon has. weakness: Weakness to an energy type (Pokemon only). resistance: Resistance to an energy type (Pokemon only). retreat_cost: Number of energy to discard to retreat (Pokemon only). trainer_type: Subtype of trainer card (Trainer only). effect_id: Effect handler for this card (Trainer/special Energy). effect_params: Parameters for the effect handler. effect_description: Human-readable description of the card's effect. energy_type: Type of energy this card provides (Energy only). energy_provides: List of energy types this card provides. Basic energy provides one of its type. Special energy may provide multiple. rarity: Card rarity (common, uncommon, rare, etc.). set_id: Which card set this belongs to. image_url: URL to the card image (CDN). image_path: Local path to the card image (e.g., "pokemon/a1/001-bulbasaur.webp"). illustrator: Artist who illustrated this card. flavor_text: Flavor text on the card (Pokemon only, typically). """ id: str name: str card_type: CardType # Pokemon-specific fields stage: PokemonStage | None = None variant: PokemonVariant = PokemonVariant.NORMAL evolves_from: str | None = None hp: int | None = None pokemon_type: EnergyType | None = None attacks: list[Attack] = Field(default_factory=list) abilities: list[Ability] = Field(default_factory=list) weakness: WeaknessResistance | None = None resistance: WeaknessResistance | None = None retreat_cost: int = 0 # Trainer-specific fields trainer_type: TrainerType | None = None effect_id: str | None = None effect_params: dict[str, Any] = Field(default_factory=dict) effect_description: str | None = None # Energy-specific fields energy_type: EnergyType | None = None energy_provides: list[EnergyType] = Field(default_factory=list) # Metadata rarity: str = "common" set_id: str = "" image_url: str | None = None image_path: str | None = None # Local path: "pokemon/a1/001-bulbasaur.webp" illustrator: str | None = None flavor_text: str | None = None @model_validator(mode="after") def validate_card_type_fields(self) -> "CardDefinition": """Validate that required fields are set based on card_type. Ensures that: - Pokemon cards have hp, stage, and pokemon_type - Stage 1/2 and VMAX/VSTAR Pokemon have evolves_from - Trainer cards have trainer_type - Energy cards have energy_type (auto-fills energy_provides if empty) Raises: ValueError: If required fields are missing for the card type. """ if self.card_type == CardType.POKEMON: if self.hp is None: raise ValueError("Pokemon cards must have 'hp' set") if self.hp <= 0: raise ValueError("Pokemon 'hp' must be positive") if self.stage is None: raise ValueError("Pokemon cards must have 'stage' set") if self.pokemon_type is None: raise ValueError("Pokemon cards must have 'pokemon_type' set") # Evolution chain validation if ( self.stage in (PokemonStage.STAGE_1, PokemonStage.STAGE_2) and self.evolves_from is None ): raise ValueError(f"{self.stage.value} Pokemon must have 'evolves_from' set") if ( self.variant in (PokemonVariant.VMAX, PokemonVariant.VSTAR) and self.evolves_from is None ): raise ValueError(f"{self.variant.value} Pokemon must have 'evolves_from' set") elif self.card_type == CardType.TRAINER: if self.trainer_type is None: raise ValueError("Trainer cards must have 'trainer_type' set") elif self.card_type == CardType.ENERGY: if self.energy_type is None: raise ValueError("Energy cards must have 'energy_type' set") # Auto-fill energy_provides if empty if not self.energy_provides: self.energy_provides = [self.energy_type] return self def is_pokemon(self) -> bool: """Check if this is a Pokemon card.""" return self.card_type == CardType.POKEMON def is_trainer(self) -> bool: """Check if this is a Trainer card.""" return self.card_type == CardType.TRAINER def is_energy(self) -> bool: """Check if this is an Energy card.""" return self.card_type == CardType.ENERGY def is_basic_pokemon(self) -> bool: """Check if this is a Basic Pokemon (can be played directly to bench).""" if not self.is_pokemon(): return False return self.stage == PokemonStage.BASIC def is_evolution(self) -> bool: """Check if this is an evolution Pokemon (Stage 1 or Stage 2).""" if not self.is_pokemon(): return False return self.stage in (PokemonStage.STAGE_1, PokemonStage.STAGE_2) def requires_evolution_from_variant(self) -> bool: """Check if this variant requires evolving from another variant. VMAX and VSTAR must evolve from a V Pokemon. """ return self.variant in (PokemonVariant.VMAX, PokemonVariant.VSTAR) def knockout_points(self) -> int: """Get the default knockout points for this Pokemon based on variant. Note: Actual points should come from RulesConfig.prizes.points_for_knockout() for configurability. This is a convenience method for the common case. """ if not self.is_pokemon(): return 0 points_map = { PokemonVariant.NORMAL: 1, PokemonVariant.EX: 2, PokemonVariant.GX: 2, PokemonVariant.V: 2, PokemonVariant.VMAX: 3, PokemonVariant.VSTAR: 3, } return points_map.get(self.variant, 1) class CardInstance(BaseModel): """A card instance in play with mutable state. While CardDefinition is the template, CardInstance represents a specific card during gameplay. It tracks damage, attached energy, status conditions, and other per-card state. The definition_id links back to the CardDefinition in the game's card registry. Stat Modifiers: Card effects can modify base stats using the modifier fields. These are additive: effective_stat = base_stat + modifier. For example, a Tool card that grants +20 HP would set hp_modifier=20. Attributes: instance_id: Unique identifier for this specific instance (UUID). definition_id: Reference to the CardDefinition.id. damage: Current damage on this card (Pokemon only). attached_energy: Energy cards attached to this Pokemon. These CardInstance objects are stored directly on the Pokemon, not in any zone. attached_tools: Tool cards attached to this Pokemon. These CardInstance objects are stored directly on the Pokemon, not in any zone. cards_underneath: Evolution stack - previous evolution stages are stored here when a Pokemon evolves. Index 0 is the oldest (Basic), and later indices are more recent evolutions. When the Pokemon is knocked out, all cards in this stack go to the discard pile. status_conditions: Active status conditions on this Pokemon. ability_uses_this_turn: Number of times each ability has been used this turn. Maps ability index to use count. Compared against Ability.uses_per_turn to determine if more uses of that specific ability are allowed. hp_modifier: Additive modifier to the Pokemon's base HP. Can be positive (e.g., from Tool cards) or negative (e.g., from effects). retreat_cost_modifier: Additive modifier to retreat cost. Negative values reduce the cost (e.g., Float Stone sets this to -99 to effectively make retreat free). Effective retreat cost is clamped to minimum 0. damage_modifier: Additive modifier to damage dealt by this Pokemon's attacks. Positive values increase damage, negative reduce it. attack_cost_overrides: Override the energy cost for specific attacks. Maps attack index (0-based) to a new list of EnergyType requirements. If an attack index is not in this dict, the base cost from CardDefinition is used. Example: {0: [EnergyType.COLORLESS, EnergyType.COLORLESS]} makes the first attack cost 2 colorless instead of its normal cost. evolved_from_instance_id: The CardInstance this evolved from (deprecated, use cards_underneath instead for the full evolution stack). turn_played: The turn number when this card was played/evolved. Used for evolution timing rules. turn_evolved: The turn number when this card last evolved. Used to prevent same-turn evolution chains. """ instance_id: str definition_id: str # Battle state (Pokemon only) damage: int = 0 attached_energy: list["CardInstance"] = Field(default_factory=list) attached_tools: list["CardInstance"] = Field(default_factory=list) cards_underneath: list["CardInstance"] = Field(default_factory=list) status_conditions: list[StatusCondition] = Field(default_factory=list) ability_uses_this_turn: dict[int, int] = Field(default_factory=dict) # Stat modifiers (applied by card effects, tools, abilities, etc.) hp_modifier: int = 0 retreat_cost_modifier: int = 0 damage_modifier: int = 0 attack_cost_overrides: dict[int, list[EnergyType]] = Field(default_factory=dict) # Evolution tracking evolved_from_instance_id: str | None = None turn_played: int | None = None turn_evolved: int | None = None def effective_hp(self, base_hp: int) -> int: """Calculate effective max HP including modifiers. Args: base_hp: The Pokemon's base HP (from CardDefinition.hp). Returns: Effective max HP (minimum 1 to prevent instant KO from negative modifiers). """ return max(1, base_hp + self.hp_modifier) def effective_retreat_cost(self, base_cost: int) -> int: """Calculate effective retreat cost including modifiers. Args: base_cost: The Pokemon's base retreat cost (from CardDefinition.retreat_cost). Returns: Effective retreat cost (minimum 0, can't be negative). """ return max(0, base_cost + self.retreat_cost_modifier) def effective_attack_cost( self, attack_index: int, base_cost: list[EnergyType] ) -> list[EnergyType]: """Get the effective energy cost for an attack, including any overrides. Args: attack_index: The index of the attack in CardDefinition.attacks (0-based). base_cost: The attack's base energy cost (from Attack.cost). Returns: The effective energy cost - either the override if set, or the base cost. Example: # An opponent's effect makes your first attack cost 2 more colorless instance.attack_cost_overrides[0] = base_cost + [EnergyType.COLORLESS] * 2 # A control effect completely changes the cost instance.attack_cost_overrides[1] = [EnergyType.PSYCHIC, EnergyType.PSYCHIC] """ if attack_index in self.attack_cost_overrides: return self.attack_cost_overrides[attack_index] return base_cost def is_knocked_out(self, base_hp: int) -> bool: """Check if this Pokemon is knocked out. Args: base_hp: The Pokemon's base HP (from CardDefinition.hp). Returns: True if damage >= effective HP. """ return self.damage >= self.effective_hp(base_hp) def remaining_hp(self, base_hp: int) -> int: """Calculate remaining HP. Args: base_hp: The Pokemon's base HP (from CardDefinition.hp). Returns: Remaining HP (minimum 0). """ return max(0, self.effective_hp(base_hp) - self.damage) def has_status(self, status: StatusCondition) -> bool: """Check if this Pokemon has a specific status condition.""" return status in self.status_conditions def add_status(self, status: StatusCondition) -> None: """Add a status condition, handling overrides. ASLEEP, PARALYZED, and CONFUSED override each other. POISONED and BURNED stack with everything. """ # These three override each other overriding = {StatusCondition.ASLEEP, StatusCondition.PARALYZED, StatusCondition.CONFUSED} if status in overriding: # Remove any existing overriding conditions self.status_conditions = [s for s in self.status_conditions if s not in overriding] if status not in self.status_conditions: self.status_conditions.append(status) def remove_status(self, status: StatusCondition) -> None: """Remove a status condition.""" if status in self.status_conditions: self.status_conditions.remove(status) def clear_all_status(self) -> None: """Remove all status conditions (e.g., when Pokemon retreats or evolves).""" self.status_conditions.clear() def reset_turn_state(self) -> None: """Reset per-turn state counters. Called at the start of each turn.""" self.ability_uses_this_turn.clear() def can_use_ability(self, ability: Ability, ability_index: int) -> bool: """Check if this Pokemon can use the given ability. Compares the use count for this specific ability against its uses_per_turn limit. If uses_per_turn is None, the ability has no usage limit. Args: ability: The Ability to check. ability_index: The index of the ability in the Pokemon's ability list. Returns: True if the ability can be used (hasn't hit its per-turn limit). """ if ability.uses_per_turn is None: return True # Unlimited uses uses = self.ability_uses_this_turn.get(ability_index, 0) return uses < ability.uses_per_turn def get_ability_uses(self, ability_index: int) -> int: """Get the number of times a specific ability has been used this turn. Args: ability_index: The index of the ability. Returns: Number of times the ability has been used this turn. """ return self.ability_uses_this_turn.get(ability_index, 0) def increment_ability_uses(self, ability_index: int) -> None: """Increment the usage counter for a specific ability. Args: ability_index: The index of the ability that was used. """ current = self.ability_uses_this_turn.get(ability_index, 0) self.ability_uses_this_turn[ability_index] = current + 1 def can_evolve_this_turn(self, current_turn: int) -> bool: """Check if this Pokemon can evolve this turn. Pokemon cannot evolve: - The same turn they were played - The same turn they already evolved Args: current_turn: The current turn number. Returns: True if this Pokemon can evolve. """ # Can't evolve same turn as played if self.turn_played == current_turn: return False # Can't evolve same turn as previous evolution return self.turn_evolved != current_turn def energy_count(self) -> int: """Get the number of attached energy cards.""" return len(self.attached_energy) def attach_energy(self, energy_card: "CardInstance") -> None: """Attach an energy card to this Pokemon. The energy CardInstance is stored directly on this Pokemon, not in any zone. When the Pokemon is knocked out, attached energy goes to the owner's discard. Args: energy_card: The CardInstance of the energy card to attach. """ self.attached_energy.append(energy_card) def detach_energy(self, energy_instance_id: str) -> "CardInstance | None": """Detach an energy card from this Pokemon. Args: energy_instance_id: The instance_id of the energy card to detach. Returns: The detached CardInstance, or None if not found. """ for i, energy in enumerate(self.attached_energy): if energy.instance_id == energy_instance_id: return self.attached_energy.pop(i) return None def attach_tool(self, tool_card: "CardInstance") -> None: """Attach a tool card to this Pokemon. The tool CardInstance is stored directly on this Pokemon, not in any zone. When the Pokemon is knocked out, attached tools go to the owner's discard. Args: tool_card: The CardInstance of the tool card to attach. """ self.attached_tools.append(tool_card) def detach_tool(self, tool_instance_id: str) -> "CardInstance | None": """Detach a tool card from this Pokemon. Args: tool_instance_id: The instance_id of the tool card to detach. Returns: The detached CardInstance, or None if not found. """ for i, tool in enumerate(self.attached_tools): if tool.instance_id == tool_instance_id: return self.attached_tools.pop(i) return None def get_all_attached_cards(self) -> list["CardInstance"]: """Get all cards attached to this Pokemon. Returns: List of all attached energy and tool CardInstances. """ return self.attached_energy + self.attached_tools # Rebuild model to resolve forward references for self-referential types # (CardInstance contains list[CardInstance] for attached_energy, attached_tools, cards_underneath) CardInstance.model_rebuild()