- 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
364 lines
13 KiB
Python
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
|