mantimon-tcg/backend/app/core/effects/handlers.py
Cal Corum e7431e2d1f Move enums to app/core/enums.py and set up clean module exports
Architectural refactor to eliminate circular imports and establish clean
module boundaries:

- Move enums from app/core/models/enums.py to app/core/enums.py
  (foundational module with zero dependencies)
- Update all imports across 30 files to use new enum location
- Set up clean export structure:
  - app.core.enums: canonical source for all enums
  - app.core: convenience exports for full public API
  - app.core.models: exports models only (not enums)
- Add module exports to app/core/__init__.py and app/core/effects/__init__.py
- Remove circular import workarounds from game_state.py

This enables app.core.models to export GameState without circular import
issues, since enums no longer depend on the models package.

All 826 tests passing.
2026-01-26 14:45:26 -06:00

848 lines
28 KiB
Python

"""Built-in effect handlers for Mantimon TCG.
This module provides the standard effect handlers used by most cards.
Each handler is registered using the @effect_handler decorator and can
be referenced by effect_id in card definitions.
Effect handlers follow a consistent pattern:
1. Extract parameters from context
2. Validate the action is possible
3. Apply the effect to game state
4. Return an EffectResult with details
Available Effects:
Damage:
- deal_damage: Raw damage (poison, burn, recoil, bench spread)
- attack_damage: Combat damage with modifiers, weakness, resistance
- coin_flip_damage: Damage based on coin flips
- bench_damage: Damage to benched Pokemon
Healing & Status:
- heal: Heal damage from a Pokemon
- apply_status: Apply a status condition
- remove_status: Remove a status condition
Card Movement:
- draw_cards: Draw cards from deck to hand
- discard_from_hand: Discard cards from hand
- shuffle_deck: Shuffle a player's deck
Energy & Modifiers:
- discard_energy: Discard energy from a Pokemon
- modify_hp: Change a Pokemon's HP modifier
- modify_retreat_cost: Change a Pokemon's retreat cost modifier
Evolution:
- devolve: Remove evolution stages from a Pokemon
"""
from app.core.effects.base import EffectContext, EffectResult, EffectType
from app.core.effects.registry import effect_handler
from app.core.enums import ModifierMode, StatusCondition
@effect_handler("deal_damage")
def handle_deal_damage(ctx: EffectContext) -> EffectResult:
"""Deal raw damage to a target Pokemon.
This is a primitive effect that applies damage directly without any
combat modifiers, weakness, or resistance calculations. Use this for:
- Poison/burn damage
- Recoil damage
- Ability damage that bypasses weakness/resistance
- Bench spread damage
For attack damage that should apply weakness/resistance, use attack_damage.
Params:
amount (int): Damage to deal. Required.
Target:
If target_card_id is set, damages that card.
Otherwise, damages opponent's active Pokemon.
Returns:
Success with damage dealt, or failure if no valid target.
"""
target = ctx.get_target_pokemon()
if target is None:
return EffectResult.failure("No valid target for damage")
amount = ctx.get_int_param("amount", 0)
if amount <= 0:
return EffectResult.failure("Damage amount must be positive")
target.damage += amount
details: dict = {"amount": amount, "target_id": target.instance_id}
# Check for knockout after applying damage
card_def = ctx.game.get_card_definition(target.definition_id)
if card_def and card_def.hp and target.is_knocked_out(card_def.hp):
details["knockout"] = True
details["knockout_pokemon_id"] = target.instance_id
message = f"Dealt {amount} damage"
if details.get("knockout"):
message += f" - {target.definition_id} knocked out!"
return EffectResult.success_result(
message,
effect_type=EffectType.DAMAGE,
details=details,
)
def _apply_modifier(damage: int, mode: ModifierMode, value: int) -> int:
"""Apply a damage modifier based on mode.
Args:
damage: The current damage amount.
mode: Whether to multiply or add.
value: The modifier value.
Returns:
The modified damage amount.
"""
if mode == ModifierMode.MULTIPLICATIVE:
return damage * value
else: # ADDITIVE
return damage + value
@effect_handler("attack_damage")
def handle_attack_damage(ctx: EffectContext) -> EffectResult:
"""Deal attack damage to a target Pokemon with full combat calculations.
Applies damage modifiers, weakness, and resistance in order:
1. Start with base amount
2. Add source Pokemon's damage_modifier (if include_modifiers=True)
3. Apply weakness (mode and value from card or CombatConfig)
4. Apply resistance (mode and value from card or CombatConfig)
5. Apply final damage (minimum 0)
Weakness/resistance calculation uses:
- Card-specific mode/value if defined in WeaknessResistance
- Game's CombatConfig defaults otherwise
Params:
amount (int): Base damage to deal. Required.
include_modifiers (bool): Whether to add source's damage_modifier. Default True.
apply_weakness_resistance (bool): Whether to apply weakness/resistance. Default True.
Target:
If target_card_id is set, damages that card.
Otherwise, damages opponent's active Pokemon.
Returns:
Success with damage dealt and calculation breakdown, or failure if no valid target.
"""
target = ctx.get_target_pokemon()
if target is None:
return EffectResult.failure("No valid target for damage")
amount = ctx.get_int_param("amount", 0)
if amount <= 0:
return EffectResult.failure("Damage amount must be positive")
details: dict = {
"base_amount": amount,
"target_id": target.instance_id,
}
# Step 1: Apply source Pokemon's damage modifier
include_modifiers = ctx.get_param("include_modifiers", True)
if include_modifiers:
source = ctx.get_source_pokemon()
if source and source.damage_modifier != 0:
amount += source.damage_modifier
details["damage_modifier"] = source.damage_modifier
details["after_modifier"] = amount
# Step 2: Apply weakness and resistance
apply_wr = ctx.get_param("apply_weakness_resistance", True)
target_def = ctx.get_card_definition(target) # Needed for W/R and knockout check
if apply_wr:
source = ctx.get_source_pokemon()
if source:
# Get the attacker's type from its definition
source_def = ctx.get_card_definition(source)
combat_config = ctx.rules.combat
if source_def and target_def and source_def.pokemon_type:
attacker_type = source_def.pokemon_type
# Check weakness
if target_def.weakness and target_def.weakness.energy_type == attacker_type:
weakness = target_def.weakness
mode = weakness.get_mode(combat_config.weakness_mode)
value = weakness.get_value(combat_config.weakness_value)
amount = _apply_modifier(amount, mode, value)
details["weakness"] = {
"type": attacker_type.value,
"mode": mode.value,
"value": value,
}
details["after_weakness"] = amount
# Check resistance
if target_def.resistance and target_def.resistance.energy_type == attacker_type:
resistance = target_def.resistance
mode = resistance.get_mode(combat_config.resistance_mode)
value = resistance.get_value(combat_config.resistance_value)
amount = _apply_modifier(amount, mode, value)
details["resistance"] = {
"type": attacker_type.value,
"mode": mode.value,
"value": value,
}
details["after_resistance"] = amount
# Final damage (minimum 0)
actual_damage = max(0, amount)
target.damage += actual_damage
details["final_damage"] = actual_damage
# Check for knockout after applying damage
if target_def and target_def.hp and target.is_knocked_out(target_def.hp):
details["knockout"] = True
details["knockout_pokemon_id"] = target.instance_id
message = f"Dealt {actual_damage} damage"
if details.get("knockout"):
message += f" - {target.definition_id} knocked out!"
return EffectResult.success_result(
message,
effect_type=EffectType.DAMAGE,
details=details,
)
@effect_handler("heal")
def handle_heal(ctx: EffectContext) -> EffectResult:
"""Heal damage from a Pokemon.
Params:
amount (int): Amount of damage to heal. Required.
Target:
If target_card_id is set, heals that card.
Otherwise, heals source player's active Pokemon.
Returns:
Success with amount healed, or failure if no valid target.
"""
# Default to healing source player's active if no target specified
target = ctx.get_target_card() if ctx.target_card_id else ctx.get_source_pokemon()
if target is None:
return EffectResult.failure("No valid target for healing")
amount = ctx.get_int_param("amount", 0)
if amount <= 0:
return EffectResult.failure("Heal amount must be positive")
# Don't heal more than current damage
actual_heal = min(amount, target.damage)
target.damage -= actual_heal
return EffectResult.success_result(
f"Healed {actual_heal} damage",
effect_type=EffectType.HEAL,
details={"amount": actual_heal, "target_id": target.instance_id},
)
@effect_handler("draw_cards")
def handle_draw_cards(ctx: EffectContext) -> EffectResult:
"""Draw cards from deck to hand.
Params:
count (int): Number of cards to draw. Default 1.
Draws cards for the source player.
Returns:
Success with number of cards drawn, or failure if deck is empty.
"""
count = ctx.get_int_param("count", 1)
if count <= 0:
return EffectResult.failure("Draw count must be positive")
player = ctx.get_source_player()
cards_drawn = 0
drawn_ids = []
for _ in range(count):
card = player.deck.draw()
if card is None:
break # Deck empty
player.hand.add(card)
cards_drawn += 1
drawn_ids.append(card.instance_id)
if cards_drawn == 0:
return EffectResult.failure("Deck is empty")
message = f"Drew {cards_drawn} card{'s' if cards_drawn != 1 else ''}"
return EffectResult.success_result(
message,
effect_type=EffectType.DRAW,
details={"count": cards_drawn, "card_ids": drawn_ids},
)
@effect_handler("discard_from_hand")
def handle_discard_from_hand(ctx: EffectContext) -> EffectResult:
"""Discard cards from hand to discard pile.
Params:
card_ids (list[str]): Instance IDs of cards to discard. Required.
Discards cards from the source player's hand.
Returns:
Success with number discarded, or failure if cards not in hand.
"""
card_ids = ctx.get_param("card_ids", [])
if not card_ids:
return EffectResult.failure("No cards specified to discard")
player = ctx.get_source_player()
discarded = []
for card_id in card_ids:
card = player.hand.remove(card_id)
if card:
player.discard.add(card)
discarded.append(card_id)
if not discarded:
return EffectResult.failure("None of the specified cards were in hand")
message = f"Discarded {len(discarded)} card{'s' if len(discarded) != 1 else ''}"
return EffectResult.success_result(
message,
effect_type=EffectType.DISCARD,
details={"count": len(discarded), "card_ids": discarded},
)
@effect_handler("apply_status")
def handle_apply_status(ctx: EffectContext) -> EffectResult:
"""Apply a status condition to a Pokemon.
Params:
status (str): The StatusCondition value to apply. Required.
One of: poisoned, burned, asleep, paralyzed, confused
Target:
If target_card_id is set, applies to that card.
Otherwise, applies to opponent's active Pokemon.
Returns:
Success with status applied, or failure if invalid.
"""
status_str = ctx.get_str_param("status")
if not status_str:
return EffectResult.failure("No status specified")
try:
status = StatusCondition(status_str)
except ValueError:
return EffectResult.failure(f"Invalid status condition: {status_str}")
target = ctx.get_target_pokemon()
if target is None:
return EffectResult.failure("No valid target for status")
target.add_status(status)
return EffectResult.success_result(
f"Applied {status.value}",
effect_type=EffectType.STATUS,
details={"status": status.value, "target_id": target.instance_id},
)
@effect_handler("remove_status")
def handle_remove_status(ctx: EffectContext) -> EffectResult:
"""Remove a status condition from a Pokemon.
Params:
status (str): The StatusCondition value to remove. Required.
all (bool): If True, remove all status conditions. Default False.
Target:
If target_card_id is set, removes from that card.
Otherwise, removes from source player's active Pokemon.
Returns:
Success with status removed.
"""
# Default to source player's active for self-healing effects
target = ctx.get_target_card() if ctx.target_card_id else ctx.get_source_pokemon()
if target is None:
return EffectResult.failure("No valid target for status removal")
remove_all = ctx.get_param("all", False)
if remove_all:
target.clear_all_status()
return EffectResult.success_result(
"Removed all status conditions",
effect_type=EffectType.STATUS,
details={"removed": "all", "target_id": target.instance_id},
)
status_str = ctx.get_str_param("status")
if not status_str:
return EffectResult.failure("No status specified")
try:
status = StatusCondition(status_str)
except ValueError:
return EffectResult.failure(f"Invalid status condition: {status_str}")
had_status = target.has_status(status)
target.remove_status(status)
if had_status:
return EffectResult.success_result(
f"Removed {status.value}",
effect_type=EffectType.STATUS,
details={"status": status.value, "target_id": target.instance_id},
)
else:
return EffectResult.success_result(
f"Target did not have {status.value}",
effect_type=EffectType.STATUS,
details={"status": status.value, "target_id": target.instance_id, "had_status": False},
)
# TODO: KNOCKOUT DETECTION FOR EFFECT-BASED DAMAGE
#
# The handlers below (coin_flip_damage, bench_damage) apply damage but do NOT
# currently detect knockouts. This is intentional for now because:
#
# 1. The engine doesn't yet execute attack effects - only base damage is applied
# 2. When effect execution is added, the engine should handle knockout detection
# AFTER all effects resolve, not during each effect
# 3. The deal_damage/attack_damage handlers set knockout flags as informational
# markers that the engine can use to identify which Pokemon to check
#
# When implementing effect execution in engine.py:
# - Execute all effects and collect EffectResults
# - Check details["knockout"] flag in each result
# - Call process_knockout() for each unique knockout_pokemon_id
# - Handle both active and bench knockouts (including multi-knockout scenarios)
#
# See: engine.py _execute_attack() for the TODO on integrating this
@effect_handler("coin_flip_damage")
def handle_coin_flip_damage(ctx: EffectContext) -> EffectResult:
"""Deal damage based on coin flip results.
Note: This handler does NOT check for knockouts. The engine is responsible
for knockout detection after all effects resolve. See module-level TODO.
Params:
damage_per_heads (int): Damage dealt per heads result. Required.
flip_count (int): Number of coins to flip. Default 1.
flip_until_tails (bool): If True, flip until tails and count heads. Default False.
Target:
Opponent's active Pokemon.
Returns:
Success with total damage and flip results.
"""
damage_per_heads = ctx.get_int_param("damage_per_heads", 0)
if damage_per_heads <= 0:
return EffectResult.failure("damage_per_heads must be positive")
flip_until_tails = ctx.get_param("flip_until_tails", False)
if flip_until_tails:
# Flip until tails
heads_count = 0
flips = []
while True:
flip = ctx.flip_coin()
flips.append(flip)
if flip:
heads_count += 1
else:
break
else:
# Fixed number of flips
flip_count = ctx.get_int_param("flip_count", 1)
flips = ctx.flip_coins(flip_count)
heads_count = sum(flips)
total_damage = heads_count * damage_per_heads
target = ctx.get_target_pokemon()
if target is None:
return EffectResult.failure("No valid target for damage")
if total_damage > 0:
target.damage += total_damage
flip_results = ["heads" if f else "tails" for f in flips]
message = f"Flipped {len(flips)} coin(s): {heads_count} heads. Dealt {total_damage} damage."
return EffectResult.success_result(
message,
effect_type=EffectType.COIN_FLIP,
details={
"flips": flip_results,
"heads_count": heads_count,
"damage": total_damage,
"target_id": target.instance_id,
},
)
@effect_handler("discard_energy")
def handle_discard_energy(ctx: EffectContext) -> EffectResult:
"""Discard energy from a Pokemon to its owner's discard pile.
Params:
count (int): Number of energy cards to discard. Default 1.
energy_ids (list[str]): Specific energy instance IDs to discard. Optional.
If not provided, discards from the end of attached_energy.
Target:
If target_card_id is set, discards from that Pokemon.
Otherwise, discards from source player's active Pokemon.
Note: Energy CardInstances are stored directly on the Pokemon. When discarded,
they are moved to the Pokemon owner's discard pile.
Returns:
Success with energy discarded.
"""
# Default to source player's active (for attack costs)
target = ctx.get_target_card() if ctx.target_card_id else ctx.get_source_pokemon()
if target is None:
return EffectResult.failure("No valid target for energy discard")
# Find the owner of the target Pokemon to access their discard pile
owner = None
for player in ctx.game.players.values():
if target.instance_id in player.active or target.instance_id in player.bench:
owner = player
break
if owner is None:
return EffectResult.failure("Could not find owner of target Pokemon")
energy_ids = ctx.get_param("energy_ids")
count = ctx.get_int_param("count", 1)
discarded: list[str] = []
if energy_ids:
# Discard specific energy by ID
for energy_id in energy_ids:
energy = target.detach_energy(energy_id)
if energy:
owner.discard.add(energy)
discarded.append(energy_id)
else:
# Discard from end of list
for _ in range(count):
if target.attached_energy:
energy = target.attached_energy.pop()
owner.discard.add(energy)
discarded.append(energy.instance_id)
else:
break
if not discarded:
return EffectResult.failure("No energy to discard")
message = f"Discarded {len(discarded)} energy"
return EffectResult.success_result(
message,
effect_type=EffectType.ENERGY,
details={"count": len(discarded), "energy_ids": discarded, "target_id": target.instance_id},
)
@effect_handler("devolve")
def handle_devolve(ctx: EffectContext) -> EffectResult:
"""Devolve a Pokemon by removing evolution stages.
Removes evolution cards from a Pokemon, reverting it to a previous evolution
stage. Energy, tools, damage, and status conditions remain on the devolved
Pokemon. If damage exceeds the devolved Pokemon's HP, it is knocked out.
Params:
stages (int): Number of evolution stages to remove. Default 1.
- 1: Remove most recent evolution (Stage 2 -> Stage 1, or Stage 1 -> Basic)
- 2+: Remove multiple stages (Stage 2 -> Basic with stages=2)
destination (str): Where removed evolution cards go. Default "hand".
- "hand": Return removed evolutions to owner's hand
- "discard": Send removed evolutions to owner's discard pile
Target:
The evolved Pokemon to devolve (via target_card_id). Must be an evolved
Pokemon (has cards in cards_underneath).
Returns:
Success with details of removed cards, or failure if target cannot devolve.
Includes knockout=True in details if devolve caused a knockout.
"""
target = ctx.get_target_card()
if target is None:
return EffectResult.failure("No target specified for devolve")
if not target.cards_underneath:
return EffectResult.failure("Target is not an evolved Pokemon (no cards underneath)")
# Find target's owner and zone
owner = None
zone = None
for player in ctx.game.players.values():
if target.instance_id in player.active:
owner = player
zone = player.active
break
elif target.instance_id in player.bench:
owner = player
zone = player.bench
break
if owner is None or zone is None:
return EffectResult.failure("Could not find target Pokemon in play")
stages = ctx.get_int_param("stages", 1)
destination = ctx.get_str_param("destination", "hand")
removed_cards: list[str] = []
current = target
for _ in range(stages):
if not current.cards_underneath:
break # Can't devolve further
# Get the previous evolution from the stack
previous = current.cards_underneath.pop()
# Transfer all state from current to previous
# Energy, tools, and remaining stack stay with the Pokemon
previous.attached_energy = current.attached_energy
previous.attached_tools = current.attached_tools
previous.cards_underneath = current.cards_underneath
previous.damage = current.damage
previous.status_conditions = current.status_conditions.copy()
previous.hp_modifier = current.hp_modifier
previous.damage_modifier = current.damage_modifier
previous.retreat_cost_modifier = current.retreat_cost_modifier
# Clear current's lists (they're now on previous)
current.attached_energy = []
current.attached_tools = []
current.cards_underneath = []
# Record the removed card
removed_cards.append(current.instance_id)
# Send removed evolution to destination
if destination == "hand":
owner.hand.add(current)
else:
owner.discard.add(current)
# Swap in zone: remove current, add previous
zone.remove(current.instance_id)
zone.add(previous)
# Previous becomes the new current for next iteration
current = previous
result_details: dict = {
"removed_count": len(removed_cards),
"removed_ids": removed_cards,
"destination": destination,
"devolved_pokemon_id": current.instance_id,
}
# Check for knockout after devolve (damage may exceed new lower HP)
knockout_occurred = False
if removed_cards:
card_def = ctx.game.get_card_definition(current.definition_id)
if card_def and card_def.hp and current.is_knocked_out(card_def.hp):
knockout_occurred = True
result_details["knockout"] = True
result_details["knockout_pokemon_id"] = current.instance_id
message = f"Devolved {len(removed_cards)} stage(s)"
if knockout_occurred:
message += f" - {current.definition_id} knocked out!"
return EffectResult.success_result(
message,
effect_type=EffectType.SPECIAL, # Devolve is a unique effect
details=result_details,
)
@effect_handler("modify_hp")
def handle_modify_hp(ctx: EffectContext) -> EffectResult:
"""Modify a Pokemon's HP modifier.
Params:
amount (int): Amount to add to hp_modifier. Can be negative.
Target:
If target_card_id is set, modifies that Pokemon.
Otherwise, modifies source player's active Pokemon.
Returns:
Success with new modifier value.
"""
target = ctx.get_target_card() if ctx.target_card_id else ctx.get_source_pokemon()
if target is None:
return EffectResult.failure("No valid target for HP modification")
amount = ctx.get_int_param("amount", 0)
target.hp_modifier += amount
direction = "increased" if amount > 0 else "decreased"
message = f"HP {direction} by {abs(amount)}"
return EffectResult.success_result(
message,
effect_type=EffectType.MODIFIER,
details={
"amount": amount,
"new_modifier": target.hp_modifier,
"target_id": target.instance_id,
},
)
@effect_handler("modify_retreat_cost")
def handle_modify_retreat_cost(ctx: EffectContext) -> EffectResult:
"""Modify a Pokemon's retreat cost modifier.
Params:
amount (int): Amount to add to retreat_cost_modifier. Can be negative.
Target:
If target_card_id is set, modifies that Pokemon.
Otherwise, modifies source player's active Pokemon.
Returns:
Success with new modifier value.
"""
target = ctx.get_target_card() if ctx.target_card_id else ctx.get_source_pokemon()
if target is None:
return EffectResult.failure("No valid target for retreat cost modification")
amount = ctx.get_int_param("amount", 0)
target.retreat_cost_modifier += amount
direction = "increased" if amount > 0 else "decreased"
message = f"Retreat cost {direction} by {abs(amount)}"
return EffectResult.success_result(
message,
effect_type=EffectType.MODIFIER,
details={
"amount": amount,
"new_modifier": target.retreat_cost_modifier,
"target_id": target.instance_id,
},
)
@effect_handler("shuffle_deck")
def handle_shuffle_deck(ctx: EffectContext) -> EffectResult:
"""Shuffle a player's deck.
Params:
target_opponent (bool): If True, shuffle opponent's deck. Default False.
Returns:
Success after shuffling.
"""
target_opponent = ctx.get_param("target_opponent", False)
player = ctx.get_opponent() if target_opponent else ctx.get_source_player()
player.deck.shuffle(ctx.rng)
return EffectResult.success_result(
"Shuffled deck",
effect_type=EffectType.SHUFFLE,
details={"player_id": player.player_id},
)
@effect_handler("bench_damage")
def handle_bench_damage(ctx: EffectContext) -> EffectResult:
"""Deal damage to benched Pokemon.
Note: This handler does NOT check for knockouts. The engine is responsible
for knockout detection after all effects resolve. See module-level TODO.
Params:
amount (int): Damage to deal to each target. Required.
target_opponent (bool): Target opponent's bench. Default True.
target_all (bool): Target all benched Pokemon. Default True.
target_count (int): Number of benched Pokemon to target (if not all).
Returns:
Success with total damage dealt.
"""
amount = ctx.get_int_param("amount", 0)
if amount <= 0:
return EffectResult.failure("Damage amount must be positive")
target_opponent = ctx.get_param("target_opponent", True)
target_all = ctx.get_param("target_all", True)
target_count = ctx.get_int_param("target_count", 1)
player = ctx.get_opponent() if target_opponent else ctx.get_source_player()
benched = player.bench.get_all()
if not benched:
return EffectResult.success_result(
"No benched Pokemon to damage",
effect_type=EffectType.BENCH,
details={"targets_hit": 0},
)
targets = benched if target_all else benched[:target_count]
total_damage = 0
target_ids = []
for pokemon in targets:
pokemon.damage += amount
total_damage += amount
target_ids.append(pokemon.instance_id)
message = f"Dealt {amount} damage to {len(targets)} benched Pokemon"
return EffectResult.success_result(
message,
effect_type=EffectType.BENCH,
details={
"amount_per_target": amount,
"total_damage": total_damage,
"target_ids": target_ids,
},
)