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

183 lines
5.5 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.
"""
from collections.abc import Callable
from app.core.effects.base import EffectContext, EffectResult
# 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:
# Catch any exceptions from handlers to prevent game crashes
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