mantimon-tcg/backend/app/core/models/card.py
Cal Corum 32541af682 Add card/action models with stage/variant separation
- Add CardDefinition and CardInstance models for card templates and in-game state
- Add Attack, Ability, and WeaknessResistance models for Pokemon card components
- Add 11 action types as discriminated union (PlayPokemon, Evolve, Attack, etc.)
- Split PokemonStage (BASIC, STAGE_1, STAGE_2) from PokemonVariant (NORMAL, EX, GX, V, VMAX, VSTAR)
- Stage determines evolution mechanics, variant determines knockout points
- Update PrizeConfig to use variant for knockout point calculation
- VSTAR and VMAX both worth 3 points; EX, GX, V worth 2 points; NORMAL worth 1 point

Tests: 204 passing, all linting clean
2026-01-24 22:35:31 -06:00

364 lines
13 KiB
Python

"""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
from app.core.models.enums import (
CardType,
EnergyType,
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.
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
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.
once_per_turn: If True, can only be used once per turn per Pokemon.
activation_phase: When this ability can be activated (default: main phase).
"""
name: str
effect_id: str
effect_params: dict[str, Any] = Field(default_factory=dict)
effect_description: str | None = None
once_per_turn: bool = True
class WeaknessResistance(BaseModel):
"""Weakness or resistance to a specific energy type.
Attributes:
energy_type: The energy type this applies to.
modifier: Damage modifier. For weakness, typically 2 (x2 damage).
For resistance, typically -30.
"""
energy_type: EnergyType
modifier: int
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.
"""
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
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.
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: List of CardInstance IDs for attached energy cards.
attached_tools: List of CardInstance IDs for attached tool cards.
status_conditions: Active status conditions on this Pokemon.
ability_used_this_turn: Whether an ability was used this turn.
evolved_from_instance_id: The CardInstance this evolved from.
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[str] = Field(default_factory=list)
attached_tools: list[str] = Field(default_factory=list)
status_conditions: list[StatusCondition] = Field(default_factory=list)
ability_used_this_turn: bool = False
# Evolution tracking
evolved_from_instance_id: str | None = None
turn_played: int | None = None
turn_evolved: int | None = None
def is_knocked_out(self, hp: int) -> bool:
"""Check if this Pokemon is knocked out.
Args:
hp: The Pokemon's max HP (from CardDefinition).
Returns:
True if damage >= hp.
"""
return self.damage >= hp
def remaining_hp(self, hp: int) -> int:
"""Calculate remaining HP.
Args:
hp: The Pokemon's max HP (from CardDefinition).
Returns:
Remaining HP (minimum 0).
"""
return max(0, 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 flags. Called at the start of each turn."""
self.ability_used_this_turn = False
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_instance_id: str) -> None:
"""Attach an energy card to this Pokemon.
Args:
energy_instance_id: The CardInstance.instance_id of the energy card.
"""
self.attached_energy.append(energy_instance_id)
def detach_energy(self, energy_instance_id: str) -> bool:
"""Detach an energy card from this Pokemon.
Args:
energy_instance_id: The CardInstance.instance_id of the energy card.
Returns:
True if the energy was found and removed, False otherwise.
"""
if energy_instance_id in self.attached_energy:
self.attached_energy.remove(energy_instance_id)
return True
return False