"""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.models.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, }, )