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)
391 lines
12 KiB
Python
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()
|