mantimon-tcg/backend/app/core/effects/base.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

391 lines
12 KiB
Python

"""Base types for the effect handler system.
This module defines the core types used by all effect handlers:
- EffectContext: The context object passed to every effect handler
- EffectResult: The result returned by effect handlers
- EffectType: Categorization of effects for UI/logging
EffectContext provides a clean interface for effect handlers to interact with
the game state without needing to know the internal structure. It includes
helper methods for common operations like finding cards and getting definitions.
Usage:
@effect_handler("deal_damage")
def handle_deal_damage(ctx: EffectContext) -> EffectResult:
target = ctx.get_target_pokemon()
if target is None:
return EffectResult.failure("No target specified")
amount = ctx.params.get("amount", 0)
target.damage += amount
return EffectResult.success(f"Dealt {amount} damage")
"""
from enum import StrEnum
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, Field
if TYPE_CHECKING:
from app.core.models.card import CardDefinition, CardInstance
from app.core.models.game_state import GameState, PlayerState
from app.core.rng import RandomProvider
class EffectType(StrEnum):
"""Categories of effects for logging and UI purposes.
These help the game engine and UI understand what kind of effect
occurred without parsing the effect_id.
"""
DAMAGE = "damage" # Deals damage to a Pokemon
HEAL = "heal" # Heals damage from a Pokemon
DRAW = "draw" # Draws cards from deck
DISCARD = "discard" # Discards cards
SEARCH = "search" # Searches deck for cards
SHUFFLE = "shuffle" # Shuffles deck
STATUS = "status" # Applies or removes status conditions
ENERGY = "energy" # Attaches, moves, or discards energy
BENCH = "bench" # Affects bench (swap, damage, etc.)
MODIFIER = "modifier" # Modifies stats (HP, damage, costs)
COIN_FLIP = "coin_flip" # Coin flip with conditional effect
SPECIAL = "special" # Unique effects that don't fit other categories
class EffectResult(BaseModel):
"""Result returned by an effect handler.
Effect handlers return this to indicate success/failure and provide
information for logging, UI updates, and debugging.
Attributes:
success: Whether the effect was successfully applied.
message: Human-readable description of what happened.
effect_type: Category of the effect for UI/logging.
details: Additional structured data about the effect (for logging/replay).
state_changes: List of state changes made (for undo/replay support).
"""
success: bool
message: str
effect_type: EffectType | None = None
details: dict[str, Any] = Field(default_factory=dict)
state_changes: list[dict[str, Any]] = Field(default_factory=list)
@classmethod
def success_result(
cls,
message: str,
effect_type: EffectType | None = None,
details: dict[str, Any] | None = None,
) -> "EffectResult":
"""Create a successful result.
Args:
message: Description of what happened.
effect_type: Category of the effect.
details: Additional structured data.
Returns:
An EffectResult with success=True.
"""
return cls(
success=True,
message=message,
effect_type=effect_type,
details=details or {},
)
@classmethod
def failure(cls, message: str) -> "EffectResult":
"""Create a failure result.
Args:
message: Description of why the effect failed.
Returns:
An EffectResult with success=False.
"""
return cls(success=False, message=message)
class EffectContext:
"""Context object passed to effect handlers.
Provides access to the game state and helper methods for common operations.
Effect handlers should use this rather than directly manipulating game state
when possible, as it provides a cleaner interface and enables logging.
The context is created fresh for each effect execution and is not persisted.
Attributes:
game: The current game state (mutable - handlers modify this).
source_player_id: ID of the player who triggered the effect.
source_card_id: Instance ID of the card that triggered the effect (if any).
target_player_id: ID of the target player (if applicable).
target_card_id: Instance ID of the target card (if applicable).
params: Effect-specific parameters from the card/attack definition.
rng: Random number generator for coin flips, etc.
Example:
ctx = EffectContext(
game=game_state,
source_player_id="player1",
source_card_id="pikachu-uuid",
target_card_id="charmander-uuid",
params={"amount": 30},
rng=rng,
)
result = deal_damage_handler(ctx)
"""
def __init__(
self,
game: "GameState",
source_player_id: str,
rng: "RandomProvider",
source_card_id: str | None = None,
target_player_id: str | None = None,
target_card_id: str | None = None,
params: dict[str, Any] | None = None,
) -> None:
"""Initialize an effect context.
Args:
game: The current game state.
source_player_id: ID of the player triggering the effect.
rng: Random number generator for this effect.
source_card_id: Instance ID of the source card (optional).
target_player_id: ID of the target player (optional).
target_card_id: Instance ID of the target card (optional).
params: Effect-specific parameters (optional).
"""
self.game = game
self.source_player_id = source_player_id
self.source_card_id = source_card_id
self.target_player_id = target_player_id
self.target_card_id = target_card_id
self.params = params or {}
self.rng = rng
# =========================================================================
# Player Access
# =========================================================================
def get_source_player(self) -> "PlayerState":
"""Get the player who triggered this effect.
Returns:
The source player's state.
Raises:
KeyError: If source_player_id is not in the game.
"""
return self.game.players[self.source_player_id]
def get_target_player(self) -> "PlayerState | None":
"""Get the target player, if specified.
Returns:
The target player's state, or None if no target specified.
"""
if self.target_player_id is None:
return None
return self.game.players.get(self.target_player_id)
def get_opponent(self) -> "PlayerState":
"""Get the opponent of the source player.
Convenience method for 2-player games.
Returns:
The opponent's player state.
"""
return self.game.get_opponent(self.source_player_id)
def get_opponent_id(self) -> str:
"""Get the opponent's player ID.
Returns:
The opponent's player ID.
"""
return self.game.get_opponent_id(self.source_player_id)
# =========================================================================
# Card Access
# =========================================================================
def get_source_card(self) -> "CardInstance | None":
"""Get the card that triggered this effect.
Returns:
The source CardInstance, or None if no source card.
"""
if self.source_card_id is None:
return None
card, _ = self.game.find_card_instance(self.source_card_id)
return card
def get_target_card(self) -> "CardInstance | None":
"""Get the target card, if specified.
Returns:
The target CardInstance, or None if no target specified.
"""
if self.target_card_id is None:
return None
card, _ = self.game.find_card_instance(self.target_card_id)
return card
def get_source_pokemon(self) -> "CardInstance | None":
"""Get the source card if it's the active Pokemon.
Convenience method for attack effects where the source is the attacker.
Returns:
The source player's active Pokemon, or None.
"""
return self.get_source_player().get_active_pokemon()
def get_target_pokemon(self) -> "CardInstance | None":
"""Get the target Pokemon.
If target_card_id is specified, returns that card.
Otherwise, returns the opponent's active Pokemon (common default).
Returns:
The target CardInstance, or None if not found.
"""
if self.target_card_id:
return self.get_target_card()
# Default to opponent's active
opponent = self.get_opponent()
return opponent.get_active_pokemon()
def get_card_definition(self, card: "CardInstance") -> "CardDefinition | None":
"""Look up the definition for a card instance.
Args:
card: The CardInstance to look up.
Returns:
The CardDefinition, or None if not found in registry.
"""
return self.game.get_card_definition(card.definition_id)
def find_card(self, instance_id: str) -> tuple["CardInstance | None", str | None]:
"""Find a card anywhere in the game.
Args:
instance_id: The instance ID to search for.
Returns:
Tuple of (CardInstance, zone_name) or (None, None) if not found.
"""
return self.game.find_card_instance(instance_id)
# =========================================================================
# Parameter Access
# =========================================================================
def get_param(self, key: str, default: Any = None) -> Any:
"""Get an effect parameter with optional default.
Args:
key: The parameter key.
default: Default value if key not present.
Returns:
The parameter value or default.
"""
return self.params.get(key, default)
def get_int_param(self, key: str, default: int = 0) -> int:
"""Get an integer parameter.
Args:
key: The parameter key.
default: Default value if key not present or not an int.
Returns:
The parameter as an integer.
"""
value = self.params.get(key, default)
if isinstance(value, int):
return value
try:
return int(value)
except (TypeError, ValueError):
return default
def get_str_param(self, key: str, default: str = "") -> str:
"""Get a string parameter.
Args:
key: The parameter key.
default: Default value if key not present.
Returns:
The parameter as a string.
"""
value = self.params.get(key, default)
return str(value) if value is not None else default
# =========================================================================
# Random Operations
# =========================================================================
def flip_coin(self) -> bool:
"""Flip a coin.
Returns:
True for heads, False for tails.
"""
return self.rng.coin_flip()
def flip_coins(self, count: int) -> list[bool]:
"""Flip multiple coins.
Args:
count: Number of coins to flip.
Returns:
List of results (True=heads, False=tails).
"""
return [self.rng.coin_flip() for _ in range(count)]
def count_heads(self, count: int) -> int:
"""Flip coins and count heads.
Args:
count: Number of coins to flip.
Returns:
Number of heads.
"""
return sum(self.flip_coins(count))
# =========================================================================
# Rules Access
# =========================================================================
@property
def rules(self):
"""Get the game's rules configuration.
Returns:
The RulesConfig for this game.
"""
return self.game.rules
@property
def is_first_turn(self) -> bool:
"""Check if this is the first turn of the game.
Returns:
True if this is the first turn.
"""
return self.game.is_first_turn()