mantimon-tcg/backend/app/core/models/card.py
Cal Corum dba2813f80 Add effects system with configurable weakness/resistance
Effects System (Week 3):
- EffectContext: helper methods for player/card access, params, coin flips
- EffectResult: success, message, effect_type, details for logging
- @effect_handler decorator with sync/async support and introspection
- resolve_effect() for executing effects by ID

Built-in Handlers (13 total):
- deal_damage: raw damage primitive (poison, burn, recoil)
- attack_damage: combat damage with modifiers, weakness, resistance
- heal, draw_cards, discard_from_hand, shuffle_deck
- apply_status, remove_status
- coin_flip_damage, bench_damage
- discard_energy, modify_hp, modify_retreat_cost

Configurable Weakness/Resistance:
- ModifierMode enum: MULTIPLICATIVE (x2) or ADDITIVE (+20)
- CombatConfig in RulesConfig for game-wide defaults
- WeaknessResistance supports per-card mode/value overrides
- Legacy 'modifier' field maintained for backwards compatibility

Test Coverage: 98% (418 tests)
- 84 tests for effects system (base, registry, handlers)
- Comprehensive edge case coverage for all handlers
- CardDefinition helper methods tested for non-Pokemon cards
- Zone edge cases (draw_bottom empty, peek_bottom overflow)
2026-01-25 00:25:38 -06:00

507 lines
19 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,
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.
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.
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.
"""
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.
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: 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_uses_this_turn: Number of times abilities have been used this turn.
Compared against Ability.uses_per_turn to determine if more uses 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.
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_uses_this_turn: int = 0
# 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 = 0
def can_use_ability(self, ability: Ability) -> bool:
"""Check if this Pokemon can use the given ability.
Compares ability_uses_this_turn against the ability's uses_per_turn limit.
If uses_per_turn is None, the ability has no usage limit.
Args:
ability: The Ability to check.
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
return self.ability_uses_this_turn < ability.uses_per_turn
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