mantimon-tcg/backend/app/core/effects/registry.py
Cal Corum 939ae421aa Add exception logging to effect registry (Issue #14)
Effect handler exceptions now logged at ERROR level with full context:
- effect_id, source_player_id, source/target card IDs, params
- Full traceback via logger.exception()

Game still returns safe EffectResult.failure() to prevent crashes,
but debugging information is now preserved in logs.
2026-01-26 13:32:43 -06:00

197 lines
6.0 KiB
Python

"""Effect handler registry for Mantimon TCG.
This module implements the registration and lookup system for effect handlers.
Effect handlers are functions that implement card effects like "deal 30 damage"
or "flip a coin, if heads draw 2 cards".
The registry uses a decorator pattern for registration:
@effect_handler("deal_damage")
def handle_deal_damage(ctx: EffectContext) -> EffectResult:
amount = ctx.get_int_param("amount")
target = ctx.get_target_pokemon()
target.damage += amount
return EffectResult.success_result(f"Dealt {amount} damage")
Effects are then resolved by ID at runtime:
result = resolve_effect("deal_damage", context)
This decouples card definitions (which just store effect_id and params) from
the actual effect implementations, allowing cards to be defined in JSON/database
while effect logic lives in Python.
"""
import logging
from collections.abc import Callable
from app.core.effects.base import EffectContext, EffectResult
logger = logging.getLogger(__name__)
# Type alias for effect handler functions
EffectHandler = Callable[[EffectContext], EffectResult]
# Global registry mapping effect_id to handler function
_EFFECT_REGISTRY: dict[str, EffectHandler] = {}
def effect_handler(effect_id: str) -> Callable[[EffectHandler], EffectHandler]:
"""Decorator to register an effect handler.
Use this decorator to register a function as the handler for an effect_id.
The effect_id should match what's stored in CardDefinition.effect_id or
Attack.effect_id.
Args:
effect_id: Unique identifier for this effect (e.g., "deal_damage").
Returns:
Decorator function that registers the handler.
Raises:
ValueError: If effect_id is already registered.
Example:
@effect_handler("deal_damage")
def handle_deal_damage(ctx: EffectContext) -> EffectResult:
amount = ctx.get_int_param("amount")
target = ctx.get_target_pokemon()
if target is None:
return EffectResult.failure("No target")
target.damage += amount
return EffectResult.success_result(f"Dealt {amount} damage")
"""
def decorator(func: EffectHandler) -> EffectHandler:
if effect_id in _EFFECT_REGISTRY:
raise ValueError(f"Effect handler already registered for '{effect_id}'")
_EFFECT_REGISTRY[effect_id] = func
return func
return decorator
def resolve_effect(effect_id: str, ctx: EffectContext) -> EffectResult:
"""Look up and execute an effect handler.
Args:
effect_id: The effect identifier to resolve.
ctx: The EffectContext containing game state and parameters.
Returns:
The EffectResult from the handler, or a failure result if not found.
Example:
ctx = EffectContext(game=game, source_player_id="player1", rng=rng,
params={"amount": 30})
result = resolve_effect("deal_damage", ctx)
if result.success:
print(result.message)
"""
handler = _EFFECT_REGISTRY.get(effect_id)
if handler is None:
return EffectResult.failure(f"Unknown effect: {effect_id}")
try:
return handler(ctx)
except Exception as e:
# Log full traceback for debugging, but return safe failure result
# to prevent game crashes. This preserves the exception details
# while allowing the game to continue gracefully.
logger.exception(
"Effect handler '%s' raised an exception. Context: source_player=%s, "
"source_card=%s, target_card=%s, params=%s",
effect_id,
ctx.source_player_id,
ctx.source_card_id,
ctx.target_card_id,
ctx.params,
)
return EffectResult.failure(f"Effect '{effect_id}' failed: {e}")
def get_handler(effect_id: str) -> EffectHandler | None:
"""Get a handler function without executing it.
Useful for inspection or testing.
Args:
effect_id: The effect identifier to look up.
Returns:
The handler function, or None if not registered.
"""
return _EFFECT_REGISTRY.get(effect_id)
def is_registered(effect_id: str) -> bool:
"""Check if an effect is registered.
Args:
effect_id: The effect identifier to check.
Returns:
True if a handler is registered for this effect_id.
"""
return effect_id in _EFFECT_REGISTRY
def list_effects() -> list[str]:
"""List all registered effect IDs.
Returns:
Sorted list of all registered effect identifiers.
"""
return sorted(_EFFECT_REGISTRY.keys())
def clear_registry() -> None:
"""Clear all registered handlers.
WARNING: This is primarily for testing. Do not call in production code
as it will break effect resolution.
"""
_EFFECT_REGISTRY.clear()
def register_handler(effect_id: str, handler: EffectHandler) -> None:
"""Programmatically register an effect handler.
This is an alternative to using the @effect_handler decorator.
Useful for dynamic registration or testing.
Args:
effect_id: Unique identifier for this effect.
handler: The handler function to register.
Raises:
ValueError: If effect_id is already registered.
Example:
def my_handler(ctx: EffectContext) -> EffectResult:
return EffectResult.success_result("Custom effect")
register_handler("custom_effect", my_handler)
"""
if effect_id in _EFFECT_REGISTRY:
raise ValueError(f"Effect handler already registered for '{effect_id}'")
_EFFECT_REGISTRY[effect_id] = handler
def unregister_handler(effect_id: str) -> bool:
"""Remove a registered handler.
WARNING: This is primarily for testing. Do not call in production code.
Args:
effect_id: The effect identifier to unregister.
Returns:
True if the handler was removed, False if it wasn't registered.
"""
if effect_id in _EFFECT_REGISTRY:
del _EFFECT_REGISTRY[effect_id]
return True
return False