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)
507 lines
19 KiB
Python
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
|