Issue #2 gap: Added 14 CardDefinition validation tests covering all required field checks (hp, stage, pokemon_type, evolves_from, trainer_type, energy_type) with both negative and positive test cases. Issue #7 gap: Added 4 confusion attack engine tests covering heads/tails outcomes, self-damage, self-KO with opponent scoring, and configurable damage from RulesConfig. Issue #13 documentation: Added TODO comments in engine.py and handlers.py documenting the expected pattern for knockout detection when effect execution is implemented. Effect handlers set knockout flags; engine should process knockouts after all effects resolve. 825 tests passing (+17 new tests)
848 lines
28 KiB
Python
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.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,
|
|
},
|
|
)
|