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