diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..baeba7b --- /dev/null +++ b/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "mtg-server": { + "command": "cmd", + "args": ["/c", "npx", "-y", "@modelcontextprotocol/server-mtg"], + "env": {} + }, + "scryfall": { + "command": "cmd", + "args": ["/c", "npx", "-y", "@modelcontextprotocol/server-scryfall"], + "env": {} + } + } +} \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 12bc310..6b47106 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,5 +20,5 @@ repos: rev: v1.17.1 hooks: - id: mypy - additional_dependencies: [types-all] - args: [--ignore-missing-imports] \ No newline at end of file + additional_dependencies: [types-PyYAML, types-requests] + args: [--ignore-missing-imports, --disable-error-code=misc] \ No newline at end of file diff --git a/src/manamind/core/action.py b/src/manamind/core/action.py index 4d9ec81..b74e55b 100644 --- a/src/manamind/core/action.py +++ b/src/manamind/core/action.py @@ -262,85 +262,2443 @@ def validate(self, action: Action, game_state: GameState) -> bool: return game_state.priority_player == action.player_id +class DeclareAttackersValidator(ActionValidator): + """Validates attacking creature selection during declare attackers step.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Validate attacker declarations. + + Args: + action: The declare attackers action + game_state: Current game state + + Returns: + True if the attacker declarations are valid + """ + # Must be during declare attackers phase + if game_state.phase != "combat": + return False + + # Must be the active player + if action.player_id != game_state.active_player: + return False + + # Must have priority + if game_state.priority_player != action.player_id: + return False + + player = game_state.players[action.player_id] + + # Check each attacker + for attacker_id in action.attackers: + # Find the attacking creature + attacker = None + for card in player.battlefield.cards: + if card.instance_id == attacker_id: + attacker = card + break + + if not attacker: + return False + + # Must be a creature + if not attacker.is_creature(): + return False + + # Must not be tapped + if attacker.tapped: + return False + + # Must not have summoning sickness (unless has haste) + if attacker.summoning_sick and "Haste" not in attacker.keywords: + return False + + # Creature can't attack if it has defender + if "Defender" in attacker.keywords: + return False + + return True + + +class DeclareBlockersValidator(ActionValidator): + """Validates blocking assignments during declare blockers step.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Validate blocker declarations. + + Args: + action: The declare blockers action + game_state: Current game state + + Returns: + True if the blocker declarations are valid + """ + # Must be during declare blockers phase + if game_state.phase != "combat": + return False + + # Must be the non-active player (defending player) + if action.player_id == game_state.active_player: + return False + + # Must have priority + if game_state.priority_player != action.player_id: + return False + + player = game_state.players[action.player_id] + + # Track which creatures are blocking + blocking_creatures = set() + + # Check each blocking assignment + for attacker_id, blocker_ids in action.blockers.items(): + # Verify attacker exists and is actually attacking + attacker_exists = False + for p in game_state.players: + for card in p.battlefield.cards: + if card.instance_id == attacker_id and card.attacking: + attacker_exists = True + break + + if not attacker_exists: + return False + + # Check each blocker + for blocker_id in blocker_ids: + # Can't use same creature to block multiple attackers + if blocker_id in blocking_creatures: + return False + blocking_creatures.add(blocker_id) + + # Find the blocking creature + blocker = None + for card in player.battlefield.cards: + if card.instance_id == blocker_id: + blocker = card + break + + if not blocker: + return False + + # Must be a creature + if not blocker.is_creature(): + return False + + # Must not be tapped + if blocker.tapped: + return False + + # Can't block if creature can't block + if "Can't block" in blocker.oracle_text: + return False + + return True + + +class AssignCombatDamageValidator(ActionValidator): + """Validates combat damage assignment during first strike or damage.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Validate combat damage assignments. + + Args: + action: The assign combat damage action + game_state: Current game state + + Returns: + True if the damage assignments are valid + """ + # Must be during combat damage phase + if game_state.phase != "combat": + return False + + # Must have priority + if game_state.priority_player != action.player_id: + return False + + # Check each damage assignment + for source_id, damage_map in action.damage_assignment.items(): + # Find the damage source + source_card = None + for player in game_state.players: + for card in player.battlefield.cards: + if card.instance_id == source_id: + source_card = card + break + + if not source_card: + return False + + # Must be a creature in combat + if not source_card.is_creature(): + return False + + if not (source_card.attacking or source_card.blocking): + return False + + # Get creature's power for damage calculation + power = source_card.current_power() + if power is None or power <= 0: + continue + + # Sum assigned damage + total_damage = sum(damage_map.values()) + + # Can't assign more damage than power + if total_damage > power: + return False + + # All damage must be assigned (unless no valid targets) + valid_targets_exist = False + for target_id in damage_map.keys(): + # Verify target is valid + for player in game_state.players: + for card in player.battlefield.cards: + if card.instance_id == target_id: + valid_targets_exist = True + break + + if valid_targets_exist and total_damage < power: + return False + + return True + + +class OrderBlockersValidator(ActionValidator): + """Validates blocker ordering for damage assignment.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Validate blocker ordering choices. + + Args: + action: The order blockers action + game_state: Current game state + + Returns: + True if the blocker ordering is valid + """ + # Must be during combat phase + if game_state.phase != "combat": + return False + + # Must be the attacking player (they choose blocker order) + if action.player_id != game_state.active_player: + return False + + # Must have priority + if game_state.priority_player != action.player_id: + return False + + # Validate the ordering makes sense with current combat state + # For each attacker with multiple blockers, order must be specified + for attacker_id, blocker_order in action.additional_choices.items(): + if not isinstance(blocker_order, list): + return False + + # Find the attacking creature + attacker = None + for card in game_state.players[action.player_id].battlefield.cards: + if card.instance_id == int(attacker_id) and card.attacking: + attacker = card + break + + if not attacker: + return False + + # Verify all blockers in the order actually exist and are + # blocking this attacker + for blocker_id in blocker_order: + blocker_found = False + for player in game_state.players: + for card in player.battlefield.cards: + if ( + card.instance_id == blocker_id + and card.blocking == int(attacker_id) + ): + blocker_found = True + break + + if not blocker_found: + return False + + return True + + +class ActivateAbilityValidator(ActionValidator): + """Validates activated ability activation (non-mana abilities).""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Validate activated ability activation. + + Args: + action: The activate ability action + game_state: Current game state + + Returns: + True if the ability activation is valid + """ + # Must have priority + if game_state.priority_player != action.player_id: + return False + + # Must have a source card + if not action.card: + return False + + player = game_state.players[action.player_id] + + # Find the source permanent + source_card = None + for card in player.battlefield.cards: + if card.instance_id == action.card.instance_id: + source_card = card + break + + # Also check other zones for abilities that can be activated + # from non-battlefield zones (e.g., graveyard, hand) + if not source_card: + # Check hand for abilities like cycling, flashback, etc. + for card in player.hand.cards: + if card.instance_id == action.card.instance_id: + source_card = card + break + + # Check graveyard for abilities like flashback, unearth, etc. + if not source_card: + for card in player.graveyard.cards: + if card.instance_id == action.card.instance_id: + source_card = card + break + + if not source_card: + return False + + # Verify the ability exists on the card + ability_text = action.additional_choices.get("ability_text", "") + if not ability_text: + return False + + # Check if the ability text appears in the card's oracle text + if ability_text not in source_card.oracle_text: + return False + + # Validate ability format: [Cost]: [Effect] + if ":" not in ability_text: + return False + + cost_part = ability_text.split(":")[0].strip() + + # Validate costs can be paid + return self._validate_ability_costs( + action, game_state, source_card, cost_part + ) + + def _validate_ability_costs( + self, + action: Action, + game_state: GameState, + source_card: Card, + cost_text: str, + ) -> bool: + """Validate that all ability costs can be paid. + + Args: + action: The activate ability action + game_state: Current game state + source_card: The card with the ability + cost_text: The cost portion of the ability + + Returns: + True if all costs can be paid + """ + player = game_state.players[action.player_id] + + # Parse common cost types + costs = [cost.strip() for cost in cost_text.split(",")] + + for cost in costs: + # Mana costs (e.g., "1", "W", "UU", "2R") + if self._is_mana_cost(cost): + required_mana = self._parse_mana_cost(cost) + if not self._can_pay_mana(player, required_mana): + return False + + # Tap cost + elif cost == "T" or cost == "{T}": + if source_card.tapped: + return False + + # Sacrifice costs + elif cost.startswith("Sacrifice"): + # Extract what to sacrifice + sacrifice_target = cost.replace("Sacrifice ", "").strip() + if not self._can_sacrifice( + player, source_card, sacrifice_target + ): + return False + + # Discard costs + elif cost.startswith("Discard"): + cards_to_discard = 1 # Default + if "a card" in cost or "Discard a card" in cost: + cards_to_discard = 1 + elif "two cards" in cost: + cards_to_discard = 2 + + if len(player.hand.cards) < cards_to_discard: + return False + + # Life payment costs + elif "Pay" in cost and "life" in cost: + # Extract life amount (e.g., "Pay 2 life") + import re + + life_match = re.search(r"Pay (\d+) life", cost) + if life_match: + life_cost = int(life_match.group(1)) + if player.life <= life_cost: + return False + + # Other costs - for now, assume they can be paid + # TODO: Add more sophisticated cost parsing + + return True + + def _is_mana_cost(self, cost: str) -> bool: + """Check if a cost string represents a mana cost.""" + import re + + # Remove braces if present + cost = cost.replace("{", "").replace("}", "") + + # Check for common mana symbols + mana_symbols = r"^[0-9WUBRG]*$" + return bool(re.match(mana_symbols, cost)) + + def _parse_mana_cost(self, cost: str) -> Dict[str, int]: + """Parse a mana cost string into a dict of mana requirements. + + Args: + cost: Mana cost string (e.g., "2RR", "WWU") + + Returns: + Dict mapping mana colors to amounts needed + """ + cost = cost.replace("{", "").replace("}", "") + mana_dict = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "colorless": 0} + + i = 0 + while i < len(cost): + char = cost[i] + if char.isdigit(): + # Handle multi-digit numbers + number = "" + while i < len(cost) and cost[i].isdigit(): + number += cost[i] + i += 1 + mana_dict["colorless"] += int(number) + elif char in "WUBRG": + mana_dict[char] += 1 + i += 1 + else: + i += 1 + + return mana_dict + + def _can_pay_mana( + self, player: Any, required_mana: Dict[str, int] + ) -> bool: + """Check if player can pay the required mana.""" + # Get player's available mana + available_mana = player.total_mana() + + # Calculate total required mana + total_required = sum(required_mana.values()) + + # Simplified check - just verify total mana available + # TODO: Implement proper colored mana checking + return bool(available_mana >= total_required) + + def _can_sacrifice( + self, player: Any, source_card: Card, sacrifice_target: str + ) -> bool: + """Check if the sacrifice cost can be paid.""" + # Common sacrifice patterns + if sacrifice_target.lower() in [ + "this", + "this permanent", + source_card.name.lower(), + ]: + # Can sacrifice the source card itself + return True + + if "creature" in sacrifice_target.lower(): + # Need a creature to sacrifice + for card in player.battlefield.cards: + if card.is_creature() and card != source_card: + return True + return False + + if "artifact" in sacrifice_target.lower(): + # Need an artifact to sacrifice + for card in player.battlefield.cards: + if "Artifact" in card.card_type and card != source_card: + return True + return False + + # For specific card names or complex conditions, assume valid + # TODO: Implement more sophisticated sacrifice validation + return True + + +class MulliganValidator(ActionValidator): + """Validates mulligan decisions during game start.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Check if the mulligan action is valid. + + Args: + action: The mulligan action + game_state: Current game state + + Returns: + True if the mulligan is valid + """ + # Mulligan only allowed before the game properly begins + if game_state.phase not in ["pregame", "mulligan"]: + return False + + player = game_state.players[action.player_id] + + # Player must have cards in hand to mulligan + if len(player.hand.cards) == 0: + return False + + # Can't mulligan if already at 0 cards + if len(player.hand.cards) <= 1: + return False + + return True + + +class KeepHandValidator(ActionValidator): + """Validates keeping opening hands.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Check if the keep hand action is valid. + + Args: + action: The keep hand action + game_state: Current game state + + Returns: + True if keeping the hand is valid + """ + # Keep hand only allowed during mulligan phase + if game_state.phase not in ["pregame", "mulligan"]: + return False + + player = game_state.players[action.player_id] + + # Player must have cards in hand to keep + if len(player.hand.cards) == 0: + return False + + return True + + +class ConcedeValidator(ActionValidator): + """Validates conceding the game.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Check if the concede action is valid. + + Args: + action: The concede action + game_state: Current game state + + Returns: + True if conceding is valid + """ + # Can always concede if the game is active + if game_state.is_game_over(): + return False + + # Must be a valid player + if action.player_id < 0 or action.player_id >= len(game_state.players): + return False + + return True + + +class DiscardValidator(ActionValidator): + """Validates discarding cards from hand.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Check if the discard action is valid. + + Args: + action: The discard action + game_state: Current game state + + Returns: + True if the discard is valid + """ + player = game_state.players[action.player_id] + + # Must have a card to discard + if not action.card: + return False + + # Card must be in player's hand + if action.card not in player.hand.cards: + return False + + return True + + +class SacrificeValidator(ActionValidator): + """Validates sacrificing permanents.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Check if the sacrifice action is valid. + + Args: + action: The sacrifice action + game_state: Current game state + + Returns: + True if the sacrifice is valid + """ + player = game_state.players[action.player_id] + + # Must have a card to sacrifice + if not action.card: + return False + + # Card must be a permanent on the battlefield + if action.card not in player.battlefield.cards: + return False + + # Player must control the permanent + return True + + +class DestroyValidator(ActionValidator): + """Validates destroying permanents.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Check if the destroy action is valid. + + Args: + action: The destroy action + game_state: Current game state + + Returns: + True if the destruction is valid + """ + # Must have a target permanent + if not action.card: + return False + + # Find the permanent on any player's battlefield + permanent_found = False + for player in game_state.players: + if action.card in player.battlefield.cards: + permanent_found = True + break + + if not permanent_found: + return False + + return True + + +class ExileValidator(ActionValidator): + """Validates exiling cards/permanents.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Check if the exile action is valid. + + Args: + action: The exile action + game_state: Current game state + + Returns: + True if the exile is valid + """ + # Must have a target card + if not action.card: + return False + + # Find the card in any zone + card_found = False + for player in game_state.players: + zones = [player.hand, player.battlefield, player.graveyard] + for zone in zones: + if action.card in zone.cards: + card_found = True + break + if card_found: + break + + return card_found + + +class ChooseTargetValidator(ActionValidator): + """Validates target selection for spells and abilities.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Validate target selection. + + Args: + action: The choose target action + game_state: Current game state + + Returns: + True if the target selection is valid + """ + # Must have targets to validate + all_targets = action.target_cards + action.target_players + if not all_targets: + return False + + # Validate each target + for target in all_targets: + if isinstance(target, Card): + if not self._is_valid_card_target(target, game_state): + return False + elif isinstance(target, int): + if not self._is_valid_player_target(target, game_state): + return False + + # Check targeting restrictions from spell/ability requirements + spell_requirements = action.additional_choices.get( + "targeting_requirements", {} + ) + if not self._meets_targeting_requirements( + all_targets, spell_requirements, game_state + ): + return False + + return True + + def _is_valid_card_target( + self, target: Card, game_state: GameState + ) -> bool: + """Check if a card is a valid target.""" + # Check if target exists in any game zone + target_exists = False + for player in game_state.players: + zones = [player.battlefield, player.graveyard, player.hand] + for zone in zones: + if target in zone.cards: + target_exists = True + break + if target_exists: + break + + if not target_exists: + return False + + # Check targeting restrictions + if "Hexproof" in target.keywords: + return False + + if "Shroud" in target.keywords: + return False + + # Check protection + for keyword in target.keywords: + if keyword.startswith("Protection from"): + # Simplified protection check + # TODO: Implement full protection rules + return False + + return True + + def _is_valid_player_target( + self, target_player_id: int, game_state: GameState + ) -> bool: + """Check if a player is a valid target.""" + return 0 <= target_player_id < len(game_state.players) + + def _meets_targeting_requirements( + self, + targets: List[Any], + requirements: Dict[str, Any], + game_state: GameState, + ) -> bool: + """Check if targets meet spell/ability requirements.""" + if not requirements: + return True + + target_count = requirements.get("count", 1) + if len(targets) != target_count: + return False + + target_type = requirements.get("type", "any") + if target_type == "creature": + for target in targets: + if isinstance(target, Card) and not target.is_creature(): + return False + elif target_type == "player": + for target in targets: + if not isinstance(target, int): + return False + + return True + + +class ChooseModeValidator(ActionValidator): + """Validates mode selection for modal spells.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Validate mode selection. + + Args: + action: The choose mode action + game_state: Current game state + + Returns: + True if the mode selection is valid + """ + if not action.modes_chosen: + return False + + if not action.card: + return False + + # Get available modes from the spell + available_modes = self._get_available_modes(action.card) + if not available_modes: + return False + + # Check that chosen modes are valid + for mode in action.modes_chosen: + if mode not in available_modes: + return False + + # Check mode selection constraints + mode_constraints = self._get_mode_constraints(action.card) + return self._validate_mode_constraints( + action.modes_chosen, mode_constraints + ) + + def _get_available_modes(self, card: Card) -> List[str]: + """Extract available modes from a modal spell.""" + modes = [] + oracle_text = card.oracle_text.lower() + + # Look for common modal patterns + if ( + "choose one" in oracle_text + or "choose any number" in oracle_text + or "choose two" in oracle_text + or "choose three" in oracle_text + or "choose" in oracle_text + and ("—" in oracle_text or "•" in oracle_text) + ): + # Parse modes from oracle text (simplified) + # In a full implementation, this would be more sophisticated + if "• " in oracle_text: + parts = oracle_text.split("• ") + for i, part in enumerate(parts[1:]): + # mode_text unused - just for parsing structure + modes.append(f"mode_{i + 1}") + + return modes + + def _get_mode_constraints(self, card: Card) -> Dict[str, Any]: + """Get constraints on mode selection.""" + oracle_text = card.oracle_text.lower() + constraints = {} + + if "choose one" in oracle_text: + constraints["min_modes"] = 1 + constraints["max_modes"] = 1 + elif "choose any number" in oracle_text: + constraints["min_modes"] = 0 + constraints["max_modes"] = len(self._get_available_modes(card)) + elif "choose two" in oracle_text: + constraints["min_modes"] = 2 + constraints["max_modes"] = 2 + + return constraints + + def _validate_mode_constraints( + self, chosen_modes: List[str], constraints: Dict[str, Any] + ) -> bool: + """Validate that mode selection meets constraints.""" + mode_count = len(chosen_modes) + min_modes: int = constraints.get("min_modes", 1) + max_modes: int = constraints.get("max_modes", 1) + + return bool(min_modes <= mode_count <= max_modes) + + +class ChooseXValueValidator(ActionValidator): + """Validates X value selection for X-cost spells.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Validate X value selection. + + Args: + action: The choose X value action + game_state: Current game state + + Returns: + True if the X value selection is valid + """ + if action.x_value is None: + return False + + # X must be non-negative + if action.x_value < 0: + return False + + if not action.card: + return False + + # Check that the spell actually has X in its cost + if not self._has_x_cost(action.card): + return False + + # Check that player can pay for X + player = game_state.players[action.player_id] + total_cost = self._calculate_total_cost(action.card, action.x_value) + + if not self._can_pay_cost(player, total_cost): + return False + + return True + + def _has_x_cost(self, card: Card) -> bool: + """Check if a card has X in its mana cost.""" + return "X" in card.mana_cost + + def _calculate_total_cost( + self, card: Card, x_value: int + ) -> Dict[str, int]: + """Calculate total mana cost including X value.""" + # Simplified cost calculation + base_cost = self._parse_mana_cost(card.mana_cost.replace("X", "0")) + base_cost["colorless"] += x_value + return base_cost + + def _parse_mana_cost(self, cost: str) -> Dict[str, int]: + """Parse a mana cost string into components.""" + cost = cost.replace("{", "").replace("}", "") + mana_dict = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "colorless": 0} + + i = 0 + while i < len(cost): + char = cost[i] + if char.isdigit(): + number = "" + while i < len(cost) and cost[i].isdigit(): + number += cost[i] + i += 1 + mana_dict["colorless"] += int(number) + elif char in "WUBRG": + mana_dict[char] += 1 + i += 1 + else: + i += 1 + + return mana_dict + + def _can_pay_cost( + self, player: Any, required_mana: Dict[str, int] + ) -> bool: + """Check if player can pay the required mana cost.""" + total_required = sum(required_mana.values()) + available_mana = player.total_mana() + return bool(available_mana >= total_required) + + +class TapForManaValidator(ActionValidator): + """Validates tapping permanents for mana.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Validate tapping for mana. + + Args: + action: The tap for mana action + game_state: Current game state + + Returns: + True if tapping for mana is valid + """ + if not action.card: + return False + + player = game_state.players[action.player_id] + + # Find the card on the battlefield + source_card = None + for card in player.battlefield.cards: + if card.instance_id == action.card.instance_id: + source_card = card + break + + if not source_card: + return False + + # Card must not already be tapped + if source_card.tapped: + return False + + # Check if the permanent can produce mana + if not self._can_produce_mana(source_card): + return False + + # Verify mana ability requirements + mana_ability = action.additional_choices.get("mana_ability", "") + if mana_ability and not self._validate_mana_ability( + source_card, mana_ability + ): + return False + + return True + + def _can_produce_mana(self, card: Card) -> bool: + """Check if a permanent can produce mana.""" + # Basic lands produce mana + basic_lands = ["Plains", "Island", "Swamp", "Mountain", "Forest"] + if any(land in card.name for land in basic_lands): + return True + + # Check for mana abilities in oracle text + oracle_lower = card.oracle_text.lower() + mana_keywords = ["add", "mana", "{w}", "{u}", "{b}", "{r}", "{g}"] + return any(keyword in oracle_lower for keyword in mana_keywords) + + def _validate_mana_ability(self, card: Card, ability_text: str) -> bool: + """Validate specific mana ability.""" + if not ability_text: + return True + + # Check if ability exists on the card + return ability_text.lower() in card.oracle_text.lower() + + +class PayManaValidator(ActionValidator): + """Validates mana payment from mana pool.""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Validate mana payment. + + Args: + action: The pay mana action + game_state: Current game state + + Returns: + True if the mana payment is valid + """ + if not action.mana_payment: + return False + + player = game_state.players[action.player_id] + + # Check that player has sufficient mana in pool + return self._can_pay_from_pool(player, action.mana_payment) + + def _can_pay_from_pool( + self, player: Any, mana_payment: Dict[str, int] + ) -> bool: + """Check if player can pay mana from their mana pool.""" + # Get player's mana pool (create if doesn't exist) + if not hasattr(player, "mana_pool"): + player.mana_pool = { + "W": 0, + "U": 0, + "B": 0, + "R": 0, + "G": 0, + "colorless": 0, + } + + # Check each mana type + for mana_type, amount in mana_payment.items(): + if amount < 0: + return False + + available = player.mana_pool.get(mana_type, 0) + if available < amount: + # Try to pay with colorless if specific color unavailable + if mana_type in ["W", "U", "B", "R", "G"]: + colorless_available = player.mana_pool.get("colorless", 0) + if colorless_available < amount - available: + return False + else: + return False + + return True + + +class ActivateManaAbilityValidator(ActionValidator): + """Validates mana ability activation (fast-resolution abilities).""" + + def validate(self, action: Action, game_state: GameState) -> bool: + """Validate mana ability activation. + + Args: + action: The activate mana ability action + game_state: Current game state + + Returns: + True if the mana ability activation is valid + """ + # Must have a source card + if not action.card: + return False + + player = game_state.players[action.player_id] + + # Find the source permanent + source_card = None + for card in player.battlefield.cards: + if card.instance_id == action.card.instance_id: + source_card = card + break + + if not source_card: + return False + + # Verify the ability exists and produces mana + ability_text = action.additional_choices.get("ability_text", "") + if not ability_text: + # Check for basic land mana abilities + if self._is_basic_land_mana_ability(source_card): + return not source_card.tapped + return False + + # Verify it's actually a mana ability + if not self._is_mana_ability(ability_text): + return False + + # Check if the ability text appears in the card's oracle text + if ability_text not in source_card.oracle_text: + return False + + # Validate costs can be paid (mana abilities usually just tap) + if ":" in ability_text: + cost_part = ability_text.split(":")[0].strip() + if cost_part == "T" or cost_part == "{T}": + return not source_card.tapped + + return True + + def _is_basic_land_mana_ability(self, card: Card) -> bool: + """Check if this is a basic land with implicit mana ability.""" + basic_lands = ["Plains", "Island", "Swamp", "Mountain", "Forest"] + return any(land_type in card.name for land_type in basic_lands) + + def _is_mana_ability(self, ability_text: str) -> bool: + """Check if an ability text represents a mana ability. + + Mana abilities are abilities that: + 1. Could add mana to a player's mana pool + 2. Don't target + 3. Aren't loyalty abilities + """ + # Look for mana production keywords + mana_keywords = ["add", "mana", "{W}", "{U}", "{B}", "{R}", "{G}"] + + return any( + keyword in ability_text.lower() for keyword in mana_keywords + ) + + # Registry of validators ACTION_VALIDATORS = { ActionType.PLAY_LAND: PlayLandValidator(), ActionType.CAST_SPELL: CastSpellValidator(), ActionType.PASS_PRIORITY: PassPriorityValidator(), + ActionType.DECLARE_ATTACKERS: DeclareAttackersValidator(), + ActionType.DECLARE_BLOCKERS: DeclareBlockersValidator(), + ActionType.ASSIGN_COMBAT_DAMAGE: AssignCombatDamageValidator(), + ActionType.ORDER_BLOCKERS: OrderBlockersValidator(), + ActionType.ACTIVATE_ABILITY: ActivateAbilityValidator(), + ActionType.ACTIVATE_MANA_ABILITY: ActivateManaAbilityValidator(), + ActionType.MULLIGAN: MulliganValidator(), + ActionType.KEEP_HAND: KeepHandValidator(), + ActionType.CONCEDE: ConcedeValidator(), + ActionType.DISCARD: DiscardValidator(), + ActionType.SACRIFICE: SacrificeValidator(), + ActionType.DESTROY: DestroyValidator(), + ActionType.EXILE: ExileValidator(), + ActionType.CHOOSE_TARGET: ChooseTargetValidator(), + ActionType.CHOOSE_MODE: ChooseModeValidator(), + ActionType.CHOOSE_X_VALUE: ChooseXValueValidator(), + ActionType.TAP_FOR_MANA: TapForManaValidator(), + ActionType.PAY_MANA: PayManaValidator(), # TODO: Add more validators } -class ActionExecutor(ABC): - """Base class for executing specific types of actions.""" +class ActionExecutor(ABC): + """Base class for executing specific types of actions.""" + + @abstractmethod + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute the action and return the new game state.""" + pass + + +class PlayLandExecutor(ActionExecutor): + """Executes land-playing actions.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + # Create a copy of the game state (TODO: implement efficient copying) + new_state = game_state.copy() + + player = new_state.players[action.player_id] + + # Move card from hand to battlefield + if action.card: + player.hand.remove_card(action.card) + player.battlefield.add_card(action.card) + + # Update lands played this turn + player.lands_played_this_turn += 1 + + # TODO: Trigger any relevant abilities + + return new_state + + +class CastSpellExecutor(ActionExecutor): + """Executes spell-casting actions.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + # Create a copy of the game state + new_state = game_state.copy() + + player = new_state.players[action.player_id] + + # Pay mana cost (simplified) + # TODO: Proper mana payment logic + + # Move card from hand to stack + if action.card: + player.hand.remove_card(action.card) + new_state.stack.append( + { + "card": action.card, + "controller": action.player_id, + "targets": action.target, + "choices": action.additional_choices, + } + ) + + # TODO: Handle targeting, additional costs, etc. + + return new_state + + +class PassPriorityExecutor(ActionExecutor): + """Executes priority passing.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + new_state = game_state.copy() + + # Pass priority to the other player + new_state.priority_player = 1 - new_state.priority_player + + # TODO: Handle stack resolution, phase changes, etc. + + return new_state + + +class DeclareAttackersExecutor(ActionExecutor): + """Executes attacker declarations.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute attacker declarations. + + Args: + action: The declare attackers action + game_state: Current game state + + Returns: + New game state with attackers declared + """ + new_state = game_state.copy() + player = new_state.players[action.player_id] + + # Set attacking status for declared attackers + for attacker_id in action.attackers: + for card in player.battlefield.cards: + if card.instance_id == attacker_id: + card.attacking = True + card.tapped = True # Attacking creatures become tapped + break + + # Add to game history + new_state.history.append( + { + "action": "declare_attackers", + "player": action.player_id, + "attackers": action.attackers.copy(), + "turn": new_state.turn_number, + } + ) + + # Pass priority to defending player for blocker declarations + new_state.priority_player = 1 - action.player_id + + return new_state + + +class DeclareBlockersExecutor(ActionExecutor): + """Executes blocking assignments.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute blocker declarations. + + Args: + action: The declare blockers action + game_state: Current game state + + Returns: + New game state with blockers declared + """ + new_state = game_state.copy() + player = new_state.players[action.player_id] + + # Set blocking status for declared blockers + for attacker_id, blocker_ids in action.blockers.items(): + for blocker_id in blocker_ids: + for card in player.battlefield.cards: + if card.instance_id == blocker_id: + card.blocking = attacker_id + break + + # Update the attacker's blocked_by list + for p in new_state.players: + for card in p.battlefield.cards: + if card.instance_id == attacker_id: + card.blocked_by.extend(blocker_ids) + break + + # Add to game history + new_state.history.append( + { + "action": "declare_blockers", + "player": action.player_id, + "blockers": action.blockers.copy(), + "turn": new_state.turn_number, + } + ) + + # Pass priority back to attacking player for damage assignment + new_state.priority_player = 1 - action.player_id + + return new_state + + +class AssignCombatDamageExecutor(ActionExecutor): + """Executes combat damage assignment.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute combat damage assignments. + + Args: + action: The assign combat damage action + game_state: Current game state + + Returns: + New game state with combat damage assigned and resolved + """ + new_state = game_state.copy() + + # Apply damage to creatures and players + for source_id, damage_map in action.damage_assignment.items(): + for target_id, damage in damage_map.items(): + if damage <= 0: + continue + + # Find target (creature or player) + target_found = False + + # Check for creature targets + for player in new_state.players: + for card in player.battlefield.cards: + if card.instance_id == target_id: + # Apply damage to creature + current_damage = card.counters.get("damage", 0) + card.counters["damage"] = current_damage + damage + + # Check if creature dies (damage >= toughness) + toughness = card.current_toughness() + if ( + toughness is not None + and card.counters["damage"] >= toughness + ): + # Move to graveyard + player.battlefield.remove_card(card) + player.graveyard.add_card(card) + card.zone = "graveyard" + + target_found = True + break + + # Check for player targets + if not target_found: + for i, player in enumerate(new_state.players): + if i == target_id: + player.life -= damage + break + + # Add to game history + new_state.history.append( + { + "action": "assign_combat_damage", + "player": action.player_id, + "damage_assignment": action.damage_assignment.copy(), + "turn": new_state.turn_number, + } + ) + + # Clean up combat (remove attacking/blocking status) + for player in new_state.players: + for card in player.battlefield.cards: + card.attacking = False + card.blocking = None + card.blocked_by.clear() + + # Move to end of combat + new_state.priority_player = new_state.active_player + + return new_state + + +class OrderBlockersExecutor(ActionExecutor): + """Executes blocker ordering for damage assignment.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute blocker ordering choices. + + Args: + action: The order blockers action + game_state: Current game state + + Returns: + New game state with blocker order established + """ + new_state = game_state.copy() + + # Store blocker order information for damage assignment + # This would typically be used by the damage assignment step + blocker_orders = {} + for attacker_id, blocker_order in action.additional_choices.items(): + blocker_orders[int(attacker_id)] = blocker_order.copy() + + # Add to game history for reference during damage assignment + new_state.history.append( + { + "action": "order_blockers", + "player": action.player_id, + "blocker_orders": blocker_orders, + "turn": new_state.turn_number, + } + ) + + # Pass priority for damage assignment + new_state.priority_player = action.player_id + + return new_state + + +class ActivateAbilityExecutor(ActionExecutor): + """Executes activated ability activation (non-mana abilities).""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute activated ability activation. + + Args: + action: The activate ability action + game_state: Current game state + + Returns: + New game state with ability activated and put on the stack + """ + new_state = game_state.copy() + + # Find the source card in the new state + source_card = self._find_source_card(action, new_state) + if not source_card: + raise ValueError("Source card not found in game state") + + # Get the ability being activated + ability_text = action.additional_choices.get("ability_text", "") + cost_part = ability_text.split(":")[0].strip() + + # Pay the costs + self._pay_ability_costs(action, new_state, source_card, cost_part) + + # Put the ability on the stack (non-mana abilities use the stack) + ability_object = { + "type": "activated_ability", + "source": source_card, + "controller": action.player_id, + "ability_text": ability_text, + "targets": action.target_cards + action.target_players, + "choices": action.additional_choices.copy(), + } + + new_state.stack.append(ability_object) + + # Add to game history + new_state.history.append( + { + "action": "activate_ability", + "player": action.player_id, + "source": source_card.name, + "ability": ability_text, + "turn": new_state.turn_number, + } + ) + + return new_state + + def _find_source_card( + self, action: Action, game_state: GameState + ) -> Optional[Card]: + """Find the source card for the ability in the game state.""" + player = game_state.players[action.player_id] + + # Check battlefield first + for card in player.battlefield.cards: + if action.card and card.instance_id == action.card.instance_id: + return card + + # Check hand for abilities like cycling + for card in player.hand.cards: + if action.card and card.instance_id == action.card.instance_id: + return card + + # Check graveyard for abilities like flashback + for card in player.graveyard.cards: + if action.card and card.instance_id == action.card.instance_id: + return card + + return None + + def _pay_ability_costs( + self, + action: Action, + game_state: GameState, + source_card: Card, + cost_text: str, + ) -> None: + """Pay the costs for activating the ability. + + Args: + action: The activate ability action + game_state: Current game state (will be modified) + source_card: The card with the ability + cost_text: The cost portion of the ability + """ + player = game_state.players[action.player_id] + costs = [cost.strip() for cost in cost_text.split(",")] + + for cost in costs: + # Mana costs + if self._is_mana_cost(cost): + required_mana = self._parse_mana_cost(cost) + self._pay_mana(player, required_mana) + + # Tap cost + elif cost == "T" or cost == "{T}": + source_card.tapped = True + + # Sacrifice costs + elif cost.startswith("Sacrifice"): + sacrifice_target = cost.replace("Sacrifice ", "").strip() + self._pay_sacrifice_cost(player, source_card, sacrifice_target) + + # Discard costs + elif cost.startswith("Discard"): + cards_to_discard = 1 # Default + if "two cards" in cost: + cards_to_discard = 2 + + # For simplicity, discard from end of hand + for _ in range(min(cards_to_discard, len(player.hand.cards))): + if player.hand.cards: + discarded = player.hand.cards.pop() + player.graveyard.add_card(discarded) + discarded.zone = "graveyard" + + # Life payment costs + elif "Pay" in cost and "life" in cost: + import re + + life_match = re.search(r"Pay (\d+) life", cost) + if life_match: + life_cost = int(life_match.group(1)) + player.life -= life_cost + + def _is_mana_cost(self, cost: str) -> bool: + """Check if a cost string represents a mana cost.""" + import re + + cost = cost.replace("{", "").replace("}", "") + mana_symbols = r"^[0-9WUBRG]*$" + return bool(re.match(mana_symbols, cost)) + + def _parse_mana_cost(self, cost: str) -> Dict[str, int]: + """Parse a mana cost string into a dict of mana requirements.""" + cost = cost.replace("{", "").replace("}", "") + mana_dict = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "colorless": 0} + + i = 0 + while i < len(cost): + char = cost[i] + if char.isdigit(): + number = "" + while i < len(cost) and cost[i].isdigit(): + number += cost[i] + i += 1 + mana_dict["colorless"] += int(number) + elif char in "WUBRG": + mana_dict[char] += 1 + i += 1 + else: + i += 1 + + return mana_dict + + def _pay_mana(self, player: Any, required_mana: Dict[str, int]) -> None: + """Pay mana costs (simplified implementation).""" + # TODO: Implement proper mana pool management + # For now, assume mana is automatically tapped from lands + total_required = sum(required_mana.values()) + + # Tap lands to pay for mana (simplified) + mana_paid = 0 + for card in player.battlefield.cards: + if mana_paid >= total_required: + break + if "Land" in card.card_type and not card.tapped: + card.tapped = True + mana_paid += 1 + + def _pay_sacrifice_cost( + self, player: Any, source_card: Card, sacrifice_target: str + ) -> None: + """Pay sacrifice costs.""" + if sacrifice_target.lower() in [ + "this", + "this permanent", + source_card.name.lower(), + ]: + # Sacrifice the source card itself + player.battlefield.remove_card(source_card) + player.graveyard.add_card(source_card) + source_card.zone = "graveyard" + elif "creature" in sacrifice_target.lower(): + # Sacrifice a creature (choose first available) + for card in player.battlefield.cards: + if card.is_creature() and card != source_card: + player.battlefield.remove_card(card) + player.graveyard.add_card(card) + card.zone = "graveyard" + break + # TODO: Handle more sacrifice patterns + + +class ActivateManaAbilityExecutor(ActionExecutor): + """Executes mana ability activation (special fast-resolution abilities).""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute mana ability activation. + + Args: + action: The activate mana ability action + game_state: Current game state + + Returns: + New game state with mana ability resolved immediately + """ + new_state = game_state.copy() + player = new_state.players[action.player_id] + + # Find the source card + source_card = None + for card in player.battlefield.cards: + if action.card and card.instance_id == action.card.instance_id: + source_card = card + break + + if not source_card: + raise ValueError("Source card not found in game state") + + # Get the ability being activated + ability_text = action.additional_choices.get("ability_text", "") + + # Pay costs and resolve immediately (mana abilities don't use stack) + if ability_text: + cost_part = ability_text.split(":")[0].strip() + if cost_part == "T" or cost_part == "{T}": + source_card.tapped = True + + # Add mana to player's mana pool + mana_produced = self._parse_mana_production(ability_text) + self._add_mana_to_pool(player, mana_produced) + else: + # Basic land mana ability + if self._is_basic_land_mana_ability(source_card): + source_card.tapped = True + mana_type = self._get_basic_land_mana_type(source_card) + self._add_mana_to_pool(player, {mana_type: 1}) + + # Add to game history + new_state.history.append( + { + "action": "activate_mana_ability", + "player": action.player_id, + "source": source_card.name, + "mana_produced": ability_text or "basic land mana", + "turn": new_state.turn_number, + } + ) + + return new_state + + def _is_basic_land_mana_ability(self, card: Card) -> bool: + """Check if this is a basic land with implicit mana ability.""" + basic_lands = ["Plains", "Island", "Swamp", "Mountain", "Forest"] + return any(land_type in card.name for land_type in basic_lands) + + def _get_basic_land_mana_type(self, card: Card) -> str: + """Get the mana type produced by a basic land.""" + if "Plains" in card.name: + return "W" + elif "Island" in card.name: + return "U" + elif "Swamp" in card.name: + return "B" + elif "Mountain" in card.name: + return "R" + elif "Forest" in card.name: + return "G" + return "colorless" + + def _parse_mana_production(self, ability_text: str) -> Dict[str, int]: + """Parse the mana production from ability text. + + Args: + ability_text: The full ability text + + Returns: + Dict mapping mana colors to amounts produced + """ + import re + + mana_dict = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "colorless": 0} + + # Look for patterns like "Add {W}", "Add {2}", "Add {R}{R}" + # This is a simplified parser + effect_part = ability_text.split(":")[-1].strip().lower() + + # Find mana symbols in braces + mana_matches = re.findall(r"\{([wubrgxc]|\d+)\}", effect_part) + for match in mana_matches: + if match.isdigit(): + mana_dict["colorless"] += int(match) + elif match == "c": + mana_dict["colorless"] += 1 + elif match in "wubrg": + mana_dict[match.upper()] += 1 + + return mana_dict + + def _add_mana_to_pool( + self, player: Any, mana_produced: Dict[str, int] + ) -> None: + """Add mana to the player's mana pool. + + Args: + player: The player receiving the mana + mana_produced: Dict of mana types and amounts to add + """ + # Initialize mana pool if it doesn't exist or is empty + if not hasattr(player, "mana_pool") or not player.mana_pool: + player.mana_pool = { + "W": 0, + "U": 0, + "B": 0, + "R": 0, + "G": 0, + "colorless": 0, + } + + # Ensure all required keys exist + for mana_type in ["W", "U", "B", "R", "G", "colorless"]: + if mana_type not in player.mana_pool: + player.mana_pool[mana_type] = 0 + + for mana_type, amount in mana_produced.items(): + player.mana_pool[mana_type] += amount + + +class MulliganExecutor(ActionExecutor): + """Executes mulligan (reshuffle, draw new hand).""" - @abstractmethod def execute(self, action: Action, game_state: GameState) -> GameState: - """Execute the action and return the new game state.""" - pass + """Execute mulligan action. + Args: + action: The mulligan action + game_state: Current game state + + Returns: + New game state after mulligan + """ + new_state = game_state.copy() + player = new_state.players[action.player_id] + + # Put hand back into library + while player.hand.cards: + card = player.hand.cards.pop() + player.library.add_card(card) + card.zone = "library" + + # Shuffle library (manual shuffle since Zone doesn't have method) + import random + + random.shuffle(player.library.cards) + + # Count existing mulligans to determine how many cards to draw + existing_mulligans = sum( + 1 + for entry in new_state.history + if entry.get("action") == "mulligan" + and entry.get("player") == action.player_id + ) + # Draw one fewer card than before + cards_to_draw = max(0, 7 - existing_mulligans - 1) + for _ in range(cards_to_draw): + if player.library.cards: + card = player.library.cards.pop(0) + player.hand.add_card(card) + card.zone = "hand" + + # Track mulligan count in history + # (since player model doesn't allow dynamic attributes) + # Count existing mulligans in history + mulligan_count = ( + sum( + 1 + for entry in new_state.history + if ( + entry.get("action") == "mulligan" + and entry.get("player") == action.player_id + ) + ) + + 1 + ) + + # Add to game history + new_state.history.append( + { + "action": "mulligan", + "player": action.player_id, + "cards_drawn": cards_to_draw, + "mulligan_count": mulligan_count, + "turn": new_state.turn_number, + } + ) + + return new_state -class PlayLandExecutor(ActionExecutor): - """Executes land-playing actions.""" + +class KeepHandExecutor(ActionExecutor): + """Executes keeping opening hand.""" def execute(self, action: Action, game_state: GameState) -> GameState: - # Create a copy of the game state (TODO: implement efficient copying) + """Execute keep hand action. + + Args: + action: The keep hand action + game_state: Current game state + + Returns: + New game state after keeping hand + """ + new_state = game_state.copy() + player = new_state.players[action.player_id] + + # Mark that player has kept their hand (record in history) + + # Add to game history + new_state.history.append( + { + "action": "keep_hand", + "player": action.player_id, + "hand_size": len(player.hand.cards), + "turn": new_state.turn_number, + } + ) + + return new_state + + +class ConcedeExecutor(ActionExecutor): + """Executes conceding the game.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute concede action. + + Args: + action: The concede action + game_state: Current game state + + Returns: + New game state after conceding + """ new_state = game_state.copy() + # Mark game as over by setting life to 0 + # This will cause is_game_over() to return True and winner() to work + player = new_state.players[action.player_id] + player.life = 0 + + # Add to game history + new_state.history.append( + { + "action": "concede", + "player": action.player_id, + "winner": new_state.winner(), + "turn": new_state.turn_number, + } + ) + + return new_state + + +class DiscardExecutor(ActionExecutor): + """Executes card discard.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute discard action. + + Args: + action: The discard action + game_state: Current game state + + Returns: + New game state after discarding + """ + new_state = game_state.copy() player = new_state.players[action.player_id] - # Move card from hand to battlefield if action.card: + # Move card from hand to graveyard player.hand.remove_card(action.card) - player.battlefield.add_card(action.card) - - # Update lands played this turn - player.lands_played_this_turn += 1 + player.graveyard.add_card(action.card) + action.card.zone = "graveyard" - # TODO: Trigger any relevant abilities + # Add to game history + new_state.history.append( + { + "action": "discard", + "player": action.player_id, + "card": action.card.name if action.card else "unknown", + "turn": new_state.turn_number, + } + ) return new_state -class CastSpellExecutor(ActionExecutor): - """Executes spell-casting actions.""" +class SacrificeExecutor(ActionExecutor): + """Executes permanent sacrifice.""" def execute(self, action: Action, game_state: GameState) -> GameState: - # Create a copy of the game state - new_state = game_state.copy() + """Execute sacrifice action. + + Args: + action: The sacrifice action + game_state: Current game state + Returns: + New game state after sacrificing + """ + new_state = game_state.copy() player = new_state.players[action.player_id] - # Pay mana cost (simplified) - # TODO: Proper mana payment logic + if action.card: + # Move permanent from battlefield to graveyard + player.battlefield.remove_card(action.card) + player.graveyard.add_card(action.card) + action.card.zone = "graveyard" + + # Reset creature states + action.card.attacking = False + action.card.blocking = None + action.card.tapped = False + + # Add to game history + new_state.history.append( + { + "action": "sacrifice", + "player": action.player_id, + "card": action.card.name if action.card else "unknown", + "turn": new_state.turn_number, + } + ) + + return new_state + + +class DestroyExecutor(ActionExecutor): + """Executes permanent destruction.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute destroy action. + + Args: + action: The destroy action + game_state: Current game state + + Returns: + New game state after destroying + """ + new_state = game_state.copy() - # Move card from hand to stack if action.card: - player.hand.remove_card(action.card) - new_state.stack.append( + # Find which player owns the permanent + owner_id = None + for i, player in enumerate(new_state.players): + if action.card in player.battlefield.cards: + owner_id = i + break + + if owner_id is not None: + owner = new_state.players[owner_id] + + # Move permanent from battlefield to graveyard + owner.battlefield.remove_card(action.card) + owner.graveyard.add_card(action.card) + action.card.zone = "graveyard" + + # Reset creature states + action.card.attacking = False + action.card.blocking = None + action.card.tapped = False + + # Add to game history + new_state.history.append( { - "card": action.card, - "controller": action.player_id, - "targets": action.target, - "choices": action.additional_choices, + "action": "destroy", + "player": action.player_id, + "card": action.card.name if action.card else "unknown", + "turn": new_state.turn_number, } ) - # TODO: Handle targeting, additional costs, etc. + return new_state + + +class ChooseTargetExecutor(ActionExecutor): + """Executes target selection.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute target selection. + + Args: + action: The choose target action + game_state: Current game state + + Returns: + New game state with targets selected + """ + new_state = game_state.copy() + + # Store target selections in game state or pending spell + if ( + hasattr(new_state, "pending_targets") + and new_state.pending_targets is not None + ): + new_state.pending_targets.update( + { + "cards": action.target_cards, + "players": action.target_players, + "permanents": action.target_permanents, + } + ) + else: + new_state.pending_targets = { + "cards": action.target_cards, + "players": action.target_players, + "permanents": action.target_permanents, + } + + # Add to game history + new_state.history.append( + { + "action": "choose_target", + "player": action.player_id, + "target_count": len( + action.target_cards + action.target_players + ), + "turn": new_state.turn_number, + } + ) return new_state -class PassPriorityExecutor(ActionExecutor): - """Executes priority passing.""" +class ChooseModeExecutor(ActionExecutor): + """Executes mode selection.""" def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute mode selection. + + Args: + action: The choose mode action + game_state: Current game state + + Returns: + New game state with modes selected + """ new_state = game_state.copy() - # Pass priority to the other player - new_state.priority_player = 1 - new_state.priority_player + # Store mode selection for the spell being cast + if hasattr(new_state, "pending_modes"): + new_state.pending_modes = action.modes_chosen + else: + new_state.pending_modes = action.modes_chosen - # TODO: Handle stack resolution, phase changes, etc. + # Add to game history + new_state.history.append( + { + "action": "choose_mode", + "player": action.player_id, + "modes": action.modes_chosen.copy(), + "spell": action.card.name if action.card else "unknown", + "turn": new_state.turn_number, + } + ) + + return new_state + + +class ChooseXValueExecutor(ActionExecutor): + """Executes X value selection.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute X value selection. + + Args: + action: The choose X value action + game_state: Current game state + + Returns: + New game state with X value set + """ + new_state = game_state.copy() + + # Store X value for the spell being cast + if hasattr(new_state, "pending_x_value"): + new_state.pending_x_value = action.x_value + else: + new_state.pending_x_value = action.x_value + + # Update mana cost of the spell if it's being cast + if ( + action.card + and hasattr(new_state, "pending_spell_cost") + and action.x_value is not None + ): + total_cost = self._calculate_total_cost( + action.card, action.x_value + ) + new_state.pending_spell_cost = total_cost + + # Add to game history + new_state.history.append( + { + "action": "choose_x_value", + "player": action.player_id, + "x_value": action.x_value, + "spell": action.card.name if action.card else "unknown", + "turn": new_state.turn_number, + } + ) + + return new_state + + def _calculate_total_cost( + self, card: Card, x_value: int + ) -> Dict[str, int]: + """Calculate total mana cost including X value.""" + # Simplified cost calculation + base_cost = self._parse_mana_cost(card.mana_cost.replace("X", "0")) + base_cost["colorless"] += x_value + return base_cost + + def _parse_mana_cost(self, cost: str) -> Dict[str, int]: + """Parse a mana cost string into components.""" + cost = cost.replace("{", "").replace("}", "") + mana_dict = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "colorless": 0} + + i = 0 + while i < len(cost): + char = cost[i] + if char.isdigit(): + number = "" + while i < len(cost) and cost[i].isdigit(): + number += cost[i] + i += 1 + mana_dict["colorless"] += int(number) + elif char in "WUBRG": + mana_dict[char] += 1 + i += 1 + else: + i += 1 + + return mana_dict + + +class TapForManaExecutor(ActionExecutor): + """Executes tapping permanents for mana.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute tapping for mana. + + Args: + action: The tap for mana action + game_state: Current game state + + Returns: + New game state with mana added to pool + """ + new_state = game_state.copy() + player = new_state.players[action.player_id] + + # Find the source card + source_card = None + for card in player.battlefield.cards: + if action.card and card.instance_id == action.card.instance_id: + source_card = card + break + + if not source_card: + raise ValueError("Source card not found in game state") + + # Tap the permanent + source_card.tapped = True + + # Add mana to player's mana pool + mana_produced = self._determine_mana_production( + source_card, action.additional_choices.get("mana_ability", "") + ) + self._add_mana_to_pool(player, mana_produced) + + # Add to game history + new_state.history.append( + { + "action": "tap_for_mana", + "player": action.player_id, + "source": source_card.name, + "mana_produced": mana_produced, + "turn": new_state.turn_number, + } + ) + + return new_state + + def _determine_mana_production( + self, card: Card, ability_text: str + ) -> Dict[str, int]: + """Determine what mana is produced by tapping.""" + # Handle basic lands + if "Plains" in card.name: + return {"W": 1, "U": 0, "B": 0, "R": 0, "G": 0, "colorless": 0} + elif "Island" in card.name: + return {"W": 0, "U": 1, "B": 0, "R": 0, "G": 0, "colorless": 0} + elif "Swamp" in card.name: + return {"W": 0, "U": 0, "B": 1, "R": 0, "G": 0, "colorless": 0} + elif "Mountain" in card.name: + return {"W": 0, "U": 0, "B": 0, "R": 1, "G": 0, "colorless": 0} + elif "Forest" in card.name: + return {"W": 0, "U": 0, "B": 0, "R": 0, "G": 1, "colorless": 0} + + # Handle explicit mana abilities + if ability_text: + return self._parse_mana_ability_production(ability_text) + + # Default to colorless mana + return {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "colorless": 1} + + def _parse_mana_ability_production( + self, ability_text: str + ) -> Dict[str, int]: + """Parse mana production from ability text.""" + import re + + mana_dict = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "colorless": 0} + + # Find mana symbols in braces + mana_matches = re.findall(r"\{([wubrgc]|\d+)\}", ability_text.lower()) + for match in mana_matches: + if match.isdigit(): + mana_dict["colorless"] += int(match) + elif match == "c": + mana_dict["colorless"] += 1 + elif match in "wubrg": + mana_dict[match.upper()] += 1 + + return mana_dict + + def _add_mana_to_pool( + self, player: Any, mana_produced: Dict[str, int] + ) -> None: + """Add mana to the player's mana pool.""" + # Initialize mana pool if it doesn't exist or is empty + if not hasattr(player, "mana_pool") or not player.mana_pool: + player.mana_pool = { + "W": 0, + "U": 0, + "B": 0, + "R": 0, + "G": 0, + "colorless": 0, + } + + # Ensure all required keys exist + for mana_type in ["W", "U", "B", "R", "G", "colorless"]: + if mana_type not in player.mana_pool: + player.mana_pool[mana_type] = 0 + + for mana_type, amount in mana_produced.items(): + player.mana_pool[mana_type] += amount + + +class PayManaExecutor(ActionExecutor): + """Executes mana payment from mana pool.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute mana payment. + + Args: + action: The pay mana action + game_state: Current game state + + Returns: + New game state with mana paid + """ + new_state = game_state.copy() + player = new_state.players[action.player_id] + + if not action.mana_payment: + return new_state + + # Ensure mana pool exists + if not hasattr(player, "mana_pool"): + player.mana_pool = { + "W": 0, + "U": 0, + "B": 0, + "R": 0, + "G": 0, + "colorless": 0, + } + + # Pay mana from pool + for mana_type, amount in action.mana_payment.items(): + if amount <= 0: + continue + + # Pay from specific mana type first + available = player.mana_pool.get(mana_type, 0) + if available >= amount: + player.mana_pool[mana_type] -= amount + else: + # Pay what we can from specific type + player.mana_pool[mana_type] = 0 + remaining = amount - available + + # Pay remaining from colorless (if applicable) + if mana_type in ["W", "U", "B", "R", "G"]: + colorless_available = player.mana_pool.get("colorless", 0) + if colorless_available >= remaining: + player.mana_pool["colorless"] -= remaining + else: + # Not enough mana - should not happen if validated + raise ValueError( + f"Insufficient mana to pay {mana_type}: {amount}" + ) + + # Add to game history + new_state.history.append( + { + "action": "pay_mana", + "player": action.player_id, + "mana_paid": action.mana_payment.copy(), + "turn": new_state.turn_number, + } + ) + + return new_state + + +class ExileExecutor(ActionExecutor): + """Executes exiling cards.""" + + def execute(self, action: Action, game_state: GameState) -> GameState: + """Execute exile action. + + Args: + action: The exile action + game_state: Current game state + + Returns: + New game state after exiling + """ + new_state = game_state.copy() + + if action.card: + # Find which player owns the card and which zone it's in + for player in new_state.players: + zones = [ + (player.hand, "hand"), + (player.battlefield, "battlefield"), + (player.graveyard, "graveyard"), + ] + + for zone, zone_name in zones: + if action.card in zone.cards: + # Remove from current zone + zone.remove_card(action.card) + + # Add to exile zone (player already has exile zone) + player.exile.add_card(action.card) + action.card.zone = "exile" + + # Reset creature states if coming from battlefield + if zone_name == "battlefield": + action.card.attacking = False + action.card.blocking = None + action.card.tapped = False + + break + + # Add to game history + new_state.history.append( + { + "action": "exile", + "player": action.player_id, + "card": action.card.name if action.card else "unknown", + "turn": new_state.turn_number, + } + ) return new_state @@ -350,6 +2708,24 @@ def execute(self, action: Action, game_state: GameState) -> GameState: ActionType.PLAY_LAND: PlayLandExecutor(), ActionType.CAST_SPELL: CastSpellExecutor(), ActionType.PASS_PRIORITY: PassPriorityExecutor(), + ActionType.DECLARE_ATTACKERS: DeclareAttackersExecutor(), + ActionType.DECLARE_BLOCKERS: DeclareBlockersExecutor(), + ActionType.ASSIGN_COMBAT_DAMAGE: AssignCombatDamageExecutor(), + ActionType.ORDER_BLOCKERS: OrderBlockersExecutor(), + ActionType.ACTIVATE_ABILITY: ActivateAbilityExecutor(), + ActionType.ACTIVATE_MANA_ABILITY: ActivateManaAbilityExecutor(), + ActionType.MULLIGAN: MulliganExecutor(), + ActionType.KEEP_HAND: KeepHandExecutor(), + ActionType.CONCEDE: ConcedeExecutor(), + ActionType.DISCARD: DiscardExecutor(), + ActionType.SACRIFICE: SacrificeExecutor(), + ActionType.DESTROY: DestroyExecutor(), + ActionType.EXILE: ExileExecutor(), + ActionType.CHOOSE_TARGET: ChooseTargetExecutor(), + ActionType.CHOOSE_MODE: ChooseModeExecutor(), + ActionType.CHOOSE_X_VALUE: ChooseXValueExecutor(), + ActionType.TAP_FOR_MANA: TapForManaExecutor(), + ActionType.PAY_MANA: PayManaExecutor(), # TODO: Add more executors } @@ -439,7 +2815,96 @@ def get_legal_actions(self, game_state: GameState) -> List[Action]: if action.is_valid(game_state): legal_actions.append(action) - # TODO: Add more action types (abilities, combat, etc.) + # Check for combat actions during combat phase + if game_state.phase == "combat": + # Declare attackers (active player only, at start of combat) + if game_state.active_player == current_player_id and not any( + card.attacking for card in current_player.battlefield.cards + ): + # Generate attacker combinations + potential_attackers = [] + for card in current_player.battlefield.cards: + if ( + card.is_creature() + and not card.tapped + and not card.summoning_sick + and "Defender" not in card.keywords + and card.instance_id is not None + ): + potential_attackers.append(card.instance_id) + + if potential_attackers: + # Add option to attack with any combination of creatures + # For simplicity, add actions for individual and all + # attackers + for attacker_id in potential_attackers: + action = Action( + action_type=ActionType.DECLARE_ATTACKERS, + player_id=current_player_id, + attackers=[attacker_id], + ) + if action.is_valid(game_state): + legal_actions.append(action) + + # Option to attack with all creatures + action = Action( + action_type=ActionType.DECLARE_ATTACKERS, + player_id=current_player_id, + attackers=potential_attackers, + ) + if action.is_valid(game_state): + legal_actions.append(action) + + # Declare blockers (defending player only) + elif game_state.active_player != current_player_id and any( + card.attacking + for card in game_state.players[ + 1 - current_player_id + ].battlefield.cards + ): + # Find attacking creatures + attackers = [] + for card in game_state.players[ + 1 - current_player_id + ].battlefield.cards: + if card.attacking and card.instance_id is not None: + attackers.append(card.instance_id) + + # Find potential blockers + potential_blockers = [] + for card in current_player.battlefield.cards: + if ( + card.is_creature() + and not card.tapped + and "Can't block" not in card.oracle_text + and card.instance_id is not None + ): + potential_blockers.append(card.instance_id) + + # Generate blocking options (simplified - just block or don't + # block) + if potential_blockers and attackers: + # Option to not block anything + action = Action( + action_type=ActionType.DECLARE_BLOCKERS, + player_id=current_player_id, + blockers={}, + ) + if action.is_valid(game_state): + legal_actions.append(action) + + # Option to block each attacker with one blocker + for attacker_id in attackers: + for blocker_id in potential_blockers: + action = Action( + action_type=ActionType.DECLARE_BLOCKERS, + player_id=current_player_id, + blockers={attacker_id: [blocker_id]}, + ) + if action.is_valid(game_state): + legal_actions.append(action) + + # TODO: Add more action types (abilities, etc.) return legal_actions diff --git a/src/manamind/core/game_state.py b/src/manamind/core/game_state.py index b16814e..efb10ca 100644 --- a/src/manamind/core/game_state.py +++ b/src/manamind/core/game_state.py @@ -214,6 +214,11 @@ class GameState: # Game history for neural network context history: List[Dict[str, Any]] = field(default_factory=list) + # Pending game state information for complex actions + pending_targets: Optional[Dict[str, Any]] = None + pending_modes: Optional[List[str]] = None + pending_x_value: Optional[int] = None + @property def current_player(self) -> Player: """Get the player whose turn it is.""" diff --git a/tests/test_ability_actions.py b/tests/test_ability_actions.py new file mode 100644 index 0000000..5ea948a --- /dev/null +++ b/tests/test_ability_actions.py @@ -0,0 +1,754 @@ +"""Tests for ability activation validators and executors.""" + +from manamind.core.action import ( + Action, + ActionType, + ActivateAbilityExecutor, + ActivateAbilityValidator, + ActivateManaAbilityExecutor, + ActivateManaAbilityValidator, +) +from manamind.core.game_state import Card, create_empty_game_state + + +class TestActivateAbilityValidator: + """Test ActivateAbilityValidator.""" + + def test_valid_ability_activation(self): + """Test valid activated ability activation.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + # Create a creature with an activated ability + creature = Card( + name="Prodigal Pyromancer", + card_type="Creature", + oracle_text="{T}: Deal 1 damage to any target.", + instance_id=1, + keywords=[], + ) + creature.tapped = False + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={ + "ability_text": "{T}: Deal 1 damage to any target." + }, + ) + + validator = ActivateAbilityValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_no_priority(self): + """Test ability activation without priority.""" + game_state = create_empty_game_state() + game_state.priority_player = 1 # Other player has priority + + creature = Card( + name="Prodigal Pyromancer", + card_type="Creature", + oracle_text="{T}: Deal 1 damage to any target.", + instance_id=1, + keywords=[], + ) + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={ + "ability_text": "{T}: Deal 1 damage to any target." + }, + ) + + validator = ActivateAbilityValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_no_source_card(self): + """Test ability activation without source card.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=None, # No source card + additional_choices={ + "ability_text": "{T}: Deal 1 damage to any target." + }, + ) + + validator = ActivateAbilityValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_card_not_found(self): + """Test ability activation with card not in game state.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + # Create a creature but don't add it to the game state + creature = Card( + name="Prodigal Pyromancer", + card_type="Creature", + oracle_text="{T}: Deal 1 damage to any target.", + instance_id=1, + keywords=[], + ) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={ + "ability_text": "{T}: Deal 1 damage to any target." + }, + ) + + validator = ActivateAbilityValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_ability_not_on_card(self): + """Test ability activation with ability not on card.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + creature = Card( + name="Grizzly Bears", + card_type="Creature", + oracle_text="", # No abilities + instance_id=1, + keywords=[], + ) + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={ + "ability_text": "{T}: Deal 1 damage to any target." + }, + ) + + validator = ActivateAbilityValidator() + assert validator.validate(action, game_state) is False + + def test_valid_graveyard_ability(self): + """Test activating ability from graveyard (like flashback).""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + # Create a card with flashback in graveyard + spell = Card( + name="Flashback Spell", + card_type="Sorcery", + oracle_text=( + "Deal 3 damage. Flashback {2}{R}: You may cast this " + "card from your graveyard for its flashback cost." + ), + instance_id=1, + keywords=[], + ) + game_state.players[0].graveyard.add_card(spell) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=spell, + additional_choices={ + "ability_text": ( + "Flashback {2}{R}: You may cast this card from " + "your graveyard for its flashback cost." + ) + }, + ) + + validator = ActivateAbilityValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_tapped_creature_tap_ability(self): + """Test activating tap ability on already tapped creature.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + creature = Card( + name="Prodigal Pyromancer", + card_type="Creature", + oracle_text="{T}: Deal 1 damage to any target.", + instance_id=1, + keywords=[], + ) + creature.tapped = True # Already tapped + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={ + "ability_text": "{T}: Deal 1 damage to any target." + }, + ) + + validator = ActivateAbilityValidator() + assert validator.validate(action, game_state) is False + + def test_valid_sacrifice_ability(self): + """Test activating sacrifice ability.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + creature = Card( + name="Sakura-Tribe Elder", + card_type="Creature", + oracle_text="Sacrifice Sakura-Tribe Elder: Search land.", + instance_id=1, + keywords=[], + ) + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={ + "ability_text": "Sacrifice Sakura-Tribe Elder: Search land." + }, + ) + + validator = ActivateAbilityValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_insufficient_mana(self): + """Test activating ability without sufficient mana.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + game_state.players[0].mana_pool = {} # No mana + + creature = Card( + name="Expensive Creature", + card_type="Creature", + oracle_text="{5}: Draw a card.", + instance_id=1, + keywords=[], + ) + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={"ability_text": "{5}: Draw a card."}, + ) + + validator = ActivateAbilityValidator() + assert validator.validate(action, game_state) is False + + def test_valid_discard_ability(self): + """Test activating ability with discard cost.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + # Add cards to hand for discard cost + for i in range(2): + card = Card(name=f"Card {i}", card_type="Instant") + game_state.players[0].hand.add_card(card) + + creature = Card( + name="Wild Mongrel", + card_type="Creature", + oracle_text="Discard a card: Wild Mongrel gets +1/+1.", + instance_id=1, + keywords=[], + ) + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={ + "ability_text": "Discard a card: Wild Mongrel gets +1/+1." + }, + ) + + validator = ActivateAbilityValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_discard_no_cards(self): + """Test activating discard ability with no cards in hand.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + # No cards in hand + + creature = Card( + name="Wild Mongrel", + card_type="Creature", + oracle_text="Discard a card: Wild Mongrel gets +1/+1.", + instance_id=1, + keywords=[], + ) + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={ + "ability_text": "Discard a card: Wild Mongrel gets +1/+1." + }, + ) + + validator = ActivateAbilityValidator() + assert validator.validate(action, game_state) is False + + +class TestActivateAbilityExecutor: + """Test ActivateAbilityExecutor.""" + + def test_execute_tap_ability(self): + """Test executing tap ability.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + creature = Card( + name="Prodigal Pyromancer", + card_type="Creature", + oracle_text="{T}: Deal 1 damage to any target.", + instance_id=1, + keywords=[], + ) + creature.tapped = False + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={ + "ability_text": "{T}: Deal 1 damage to any target." + }, + ) + + executor = ActivateAbilityExecutor() + new_state = executor.execute(action, game_state) + + # Find creature in new state + new_creature = None + for card in new_state.players[0].battlefield.cards: + if card.instance_id == 1: + new_creature = card + break + + assert new_creature is not None + assert new_creature.tapped is True # Should be tapped after activation + assert len(new_state.stack) == 1 # Ability should be on stack + assert new_state.stack[0]["type"] == "activated_ability" + + def test_execute_sacrifice_ability(self): + """Test executing sacrifice ability.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + creature = Card( + name="Sakura-Tribe Elder", + card_type="Creature", + oracle_text="Sacrifice Sakura-Tribe Elder: Search land.", + instance_id=1, + keywords=[], + ) + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={ + "ability_text": "Sacrifice Sakura-Tribe Elder: Search land." + }, + ) + + executor = ActivateAbilityExecutor() + new_state = executor.execute(action, game_state) + + # Creature should be in graveyard + assert len(new_state.players[0].battlefield.cards) == 0 + assert len(new_state.players[0].graveyard.cards) == 1 + assert ( + new_state.players[0].graveyard.cards[0].name + == "Sakura-Tribe Elder" + ) + + def test_execute_discard_ability(self): + """Test executing discard ability.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + # Add cards to hand + for i in range(3): + card = Card(name=f"Card {i}", card_type="Instant") + game_state.players[0].hand.add_card(card) + + creature = Card( + name="Wild Mongrel", + card_type="Creature", + oracle_text="Discard a card: Wild Mongrel gets +1/+1.", + instance_id=1, + keywords=[], + ) + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={ + "ability_text": "Discard a card: Wild Mongrel gets +1/+1." + }, + ) + + executor = ActivateAbilityExecutor() + new_state = executor.execute(action, game_state) + + # Should have one less card in hand and one more in graveyard + assert len(new_state.players[0].hand.cards) == 2 # 3 - 1 = 2 + assert len(new_state.players[0].graveyard.cards) == 1 + + def test_execute_mana_cost_ability(self): + """Test executing ability with mana cost.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + # Add mana-producing lands + for i in range(3): + land = Card( + name="Mountain", + card_type="Land", + instance_id=i + 10, + ) + land.tapped = False + game_state.players[0].battlefield.add_card(land) + + creature = Card( + name="Expensive Creature", + card_type="Creature", + oracle_text="{2}: Draw a card.", + instance_id=1, + keywords=[], + ) + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_ABILITY, + player_id=0, + card=creature, + additional_choices={"ability_text": "{2}: Draw a card."}, + ) + + executor = ActivateAbilityExecutor() + new_state = executor.execute(action, game_state) + + # Should have tapped 2 lands for mana + tapped_lands = 0 + for card in new_state.players[0].battlefield.cards: + if card.card_type == "Land" and card.tapped: + tapped_lands += 1 + + assert tapped_lands == 2 + + +class TestActivateManaAbilityValidator: + """Test ActivateManaAbilityValidator.""" + + def test_valid_basic_land_mana_ability(self): + """Test valid basic land mana ability activation.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + land = Card( + name="Mountain", + card_type="Land", + instance_id=1, + keywords=[], + ) + land.tapped = False + game_state.players[0].battlefield.add_card(land) + + action = Action( + action_type=ActionType.ACTIVATE_MANA_ABILITY, + player_id=0, + card=land, + ) + + validator = ActivateManaAbilityValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_tapped_land(self): + """Test mana ability activation on tapped land.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + land = Card( + name="Mountain", + card_type="Land", + instance_id=1, + keywords=[], + ) + land.tapped = True # Already tapped + game_state.players[0].battlefield.add_card(land) + + action = Action( + action_type=ActionType.ACTIVATE_MANA_ABILITY, + player_id=0, + card=land, + ) + + validator = ActivateManaAbilityValidator() + assert validator.validate(action, game_state) is False + + def test_valid_explicit_mana_ability(self): + """Test explicit mana ability activation.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + mana_dork = Card( + name="Llanowar Elves", + card_type="Creature", + oracle_text="{T}: Add {G}.", + instance_id=1, + keywords=[], + ) + mana_dork.tapped = False + game_state.players[0].battlefield.add_card(mana_dork) + + action = Action( + action_type=ActionType.ACTIVATE_MANA_ABILITY, + player_id=0, + card=mana_dork, + additional_choices={"ability_text": "{T}: Add {G}."}, + ) + + validator = ActivateManaAbilityValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_not_mana_ability(self): + """Test activating non-mana ability as mana ability.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + creature = Card( + name="Prodigal Pyromancer", + card_type="Creature", + oracle_text="{T}: Deal 1 damage to any target.", + instance_id=1, + keywords=[], + ) + creature.tapped = False + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.ACTIVATE_MANA_ABILITY, + player_id=0, + card=creature, + additional_choices={ + "ability_text": "{T}: Deal 1 damage to any target." + }, + ) + + validator = ActivateManaAbilityValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_no_source_card(self): + """Test mana ability activation without source card.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + action = Action( + action_type=ActionType.ACTIVATE_MANA_ABILITY, + player_id=0, + card=None, # No source card + ) + + validator = ActivateManaAbilityValidator() + assert validator.validate(action, game_state) is False + + def test_valid_different_basic_lands(self): + """Test mana ability activation for different basic lands.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + basic_lands = [ + ("Plains", "W"), + ("Island", "U"), + ("Swamp", "B"), + ("Mountain", "R"), + ("Forest", "G"), + ] + + for i, (land_name, mana_type) in enumerate(basic_lands): + land = Card( + name=land_name, + card_type="Land", + instance_id=i + 1, + keywords=[], + ) + land.tapped = False + game_state.players[0].battlefield.add_card(land) + + action = Action( + action_type=ActionType.ACTIVATE_MANA_ABILITY, + player_id=0, + card=land, + ) + + validator = ActivateManaAbilityValidator() + assert validator.validate(action, game_state) is True + + +class TestActivateManaAbilityExecutor: + """Test ActivateManaAbilityExecutor.""" + + def test_execute_basic_land_mana(self): + """Test executing basic land mana ability.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + land = Card( + name="Mountain", + card_type="Land", + instance_id=1, + keywords=[], + ) + land.tapped = False + game_state.players[0].battlefield.add_card(land) + + action = Action( + action_type=ActionType.ACTIVATE_MANA_ABILITY, + player_id=0, + card=land, + ) + + executor = ActivateManaAbilityExecutor() + new_state = executor.execute(action, game_state) + + # Find land in new state + new_land = None + for card in new_state.players[0].battlefield.cards: + if card.instance_id == 1: + new_land = card + break + + assert new_land is not None + assert new_land.tapped is True # Should be tapped + + # Check mana pool + player = new_state.players[0] + assert hasattr(player, "mana_pool") + assert player.mana_pool["R"] == 1 # Mountain produces red mana + + def test_execute_different_basic_lands(self): + """Test executing different basic land mana abilities.""" + basic_lands = [ + ("Plains", "W"), + ("Island", "U"), + ("Swamp", "B"), + ("Mountain", "R"), + ("Forest", "G"), + ] + + for land_name, expected_mana in basic_lands: + game_state = create_empty_game_state() + game_state.priority_player = 0 + + land = Card( + name=land_name, + card_type="Land", + instance_id=1, + keywords=[], + ) + land.tapped = False + game_state.players[0].battlefield.add_card(land) + + action = Action( + action_type=ActionType.ACTIVATE_MANA_ABILITY, + player_id=0, + card=land, + ) + + executor = ActivateManaAbilityExecutor() + new_state = executor.execute(action, game_state) + + player = new_state.players[0] + assert player.mana_pool[expected_mana] == 1 + + def test_execute_explicit_mana_ability(self): + """Test executing explicit mana ability.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + mana_dork = Card( + name="Llanowar Elves", + card_type="Creature", + oracle_text="{T}: Add {G}.", + instance_id=1, + keywords=[], + ) + mana_dork.tapped = False + game_state.players[0].battlefield.add_card(mana_dork) + + action = Action( + action_type=ActionType.ACTIVATE_MANA_ABILITY, + player_id=0, + card=mana_dork, + additional_choices={"ability_text": "{T}: Add {G}."}, + ) + + executor = ActivateManaAbilityExecutor() + new_state = executor.execute(action, game_state) + + # Find creature in new state + new_creature = None + for card in new_state.players[0].battlefield.cards: + if card.instance_id == 1: + new_creature = card + break + + assert new_creature is not None + assert new_creature.tapped is True + + # Check mana pool + player = new_state.players[0] + assert player.mana_pool["G"] == 1 + + def test_execute_multiple_mana_production(self): + """Test executing mana ability that produces multiple mana.""" + game_state = create_empty_game_state() + game_state.priority_player = 0 + + mana_rock = Card( + name="Sol Ring", + card_type="Artifact", + oracle_text="{T}: Add {C}{C}.", + instance_id=1, + keywords=[], + ) + mana_rock.tapped = False + game_state.players[0].battlefield.add_card(mana_rock) + + action = Action( + action_type=ActionType.ACTIVATE_MANA_ABILITY, + player_id=0, + card=mana_rock, + additional_choices={"ability_text": "{T}: Add {C}{C}."}, + ) + + executor = ActivateManaAbilityExecutor() + new_state = executor.execute(action, game_state) + + # Check mana pool - should have 2 colorless mana + player = new_state.players[0] + assert player.mana_pool["colorless"] == 2 diff --git a/tests/test_basic_actions.py b/tests/test_basic_actions.py new file mode 100644 index 0000000..1f807c8 --- /dev/null +++ b/tests/test_basic_actions.py @@ -0,0 +1,815 @@ +"""Tests for basic game action validators and executors.""" + +from manamind.core.action import ( + Action, + ActionType, + ConcedeExecutor, + ConcedeValidator, + DestroyExecutor, + DestroyValidator, + DiscardExecutor, + DiscardValidator, + ExileExecutor, + ExileValidator, + KeepHandExecutor, + KeepHandValidator, + MulliganExecutor, + MulliganValidator, + SacrificeExecutor, + SacrificeValidator, +) +from manamind.core.game_state import Card, create_empty_game_state + + +class TestMulliganValidator: + """Test MulliganValidator.""" + + def test_valid_mulligan(self): + """Test valid mulligan during pregame.""" + game_state = create_empty_game_state() + game_state.phase = "mulligan" + + # Add cards to hand + for i in range(7): + card = Card(name=f"Card {i}", card_type="Instant") + game_state.players[0].hand.add_card(card) + + action = Action( + action_type=ActionType.MULLIGAN, + player_id=0, + ) + + validator = MulliganValidator() + assert validator.validate(action, game_state) is True + + def test_valid_mulligan_pregame_phase(self): + """Test valid mulligan during pregame phase.""" + game_state = create_empty_game_state() + game_state.phase = "pregame" + + # Add cards to hand + for i in range(7): + card = Card(name=f"Card {i}", card_type="Instant") + game_state.players[0].hand.add_card(card) + + action = Action( + action_type=ActionType.MULLIGAN, + player_id=0, + ) + + validator = MulliganValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_mulligan_wrong_phase(self): + """Test mulligan during wrong phase.""" + game_state = create_empty_game_state() + game_state.phase = "main" # Wrong phase + + action = Action( + action_type=ActionType.MULLIGAN, + player_id=0, + ) + + validator = MulliganValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_mulligan_no_cards(self): + """Test mulligan with no cards in hand.""" + game_state = create_empty_game_state() + game_state.phase = "mulligan" + # No cards in hand + + action = Action( + action_type=ActionType.MULLIGAN, + player_id=0, + ) + + validator = MulliganValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_mulligan_one_card(self): + """Test mulligan with only one card in hand.""" + game_state = create_empty_game_state() + game_state.phase = "mulligan" + + # Add only one card to hand + card = Card(name="Card", card_type="Instant") + game_state.players[0].hand.add_card(card) + + action = Action( + action_type=ActionType.MULLIGAN, + player_id=0, + ) + + validator = MulliganValidator() + assert validator.validate(action, game_state) is False + + +class TestMulliganExecutor: + """Test MulliganExecutor.""" + + def test_execute_mulligan(self): + """Test executing mulligan.""" + game_state = create_empty_game_state() + game_state.phase = "mulligan" + + # Add cards to hand and library + hand_cards = [] + for i in range(7): + card = Card(name=f"Hand Card {i}", card_type="Instant") + hand_cards.append(card) + game_state.players[0].hand.add_card(card) + + for i in range(20): + card = Card(name=f"Library Card {i}", card_type="Instant") + game_state.players[0].library.add_card(card) + + action = Action( + action_type=ActionType.MULLIGAN, + player_id=0, + ) + + executor = MulliganExecutor() + new_state = executor.execute(action, game_state) + + # Should have 6 cards in hand (7 - 1 for first mulligan) + assert len(new_state.players[0].hand.cards) == 6 + + # Should have shuffled cards back into library + assert ( + len(new_state.players[0].library.cards) >= 20 + ) # At least original library + + # Check mulligan count in history + mulligan_actions = [ + entry + for entry in new_state.history + if entry.get("action") == "mulligan" and entry.get("player") == 0 + ] + assert len(mulligan_actions) == 1 + assert mulligan_actions[0]["mulligan_count"] == 1 + + def test_execute_multiple_mulligans(self): + """Test executing multiple mulligans reduces hand size.""" + game_state = create_empty_game_state() + game_state.phase = "mulligan" + + # Simulate player already having one mulligan by adding it to history + game_state.history.append( + { + "action": "mulligan", + "player": 0, + "cards_drawn": 6, + "mulligan_count": 1, + "turn": 1, + } + ) + + # Add cards to hand and library + for i in range(6): # 6 cards from previous mulligan + card = Card(name=f"Hand Card {i}", card_type="Instant") + game_state.players[0].hand.add_card(card) + + for i in range(20): + card = Card(name=f"Library Card {i}", card_type="Instant") + game_state.players[0].library.add_card(card) + + action = Action( + action_type=ActionType.MULLIGAN, + player_id=0, + ) + + executor = MulliganExecutor() + new_state = executor.execute(action, game_state) + + # Should have 5 cards in hand (6 - 1 for second mulligan) + assert len(new_state.players[0].hand.cards) == 5 + + # Check mulligan count in history + mulligan_actions = [ + entry + for entry in new_state.history + if entry.get("action") == "mulligan" and entry.get("player") == 0 + ] + assert len(mulligan_actions) == 2 + assert ( + mulligan_actions[-1]["mulligan_count"] == 2 + ) # Latest mulligan should be count 2 + + +class TestKeepHandValidator: + """Test KeepHandValidator.""" + + def test_valid_keep_hand(self): + """Test valid keep hand during mulligan phase.""" + game_state = create_empty_game_state() + game_state.phase = "mulligan" + + # Add cards to hand + for i in range(7): + card = Card(name=f"Card {i}", card_type="Instant") + game_state.players[0].hand.add_card(card) + + action = Action( + action_type=ActionType.KEEP_HAND, + player_id=0, + ) + + validator = KeepHandValidator() + assert validator.validate(action, game_state) is True + + def test_valid_keep_hand_pregame(self): + """Test valid keep hand during pregame phase.""" + game_state = create_empty_game_state() + game_state.phase = "pregame" + + # Add cards to hand + for i in range(6): # After one mulligan + card = Card(name=f"Card {i}", card_type="Instant") + game_state.players[0].hand.add_card(card) + + action = Action( + action_type=ActionType.KEEP_HAND, + player_id=0, + ) + + validator = KeepHandValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_keep_hand_wrong_phase(self): + """Test keep hand during wrong phase.""" + game_state = create_empty_game_state() + game_state.phase = "main" # Wrong phase + + action = Action( + action_type=ActionType.KEEP_HAND, + player_id=0, + ) + + validator = KeepHandValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_keep_hand_no_cards(self): + """Test keep hand with no cards.""" + game_state = create_empty_game_state() + game_state.phase = "mulligan" + # No cards in hand + + action = Action( + action_type=ActionType.KEEP_HAND, + player_id=0, + ) + + validator = KeepHandValidator() + assert validator.validate(action, game_state) is False + + +class TestKeepHandExecutor: + """Test KeepHandExecutor.""" + + def test_execute_keep_hand(self): + """Test executing keep hand.""" + game_state = create_empty_game_state() + game_state.phase = "mulligan" + + # Add cards to hand + for i in range(7): + card = Card(name=f"Card {i}", card_type="Instant") + game_state.players[0].hand.add_card(card) + + action = Action( + action_type=ActionType.KEEP_HAND, + player_id=0, + ) + + executor = KeepHandExecutor() + new_state = executor.execute(action, game_state) + + # Hand should remain unchanged + assert len(new_state.players[0].hand.cards) == 7 + + # Should mark that player kept their hand in history + keep_actions = [ + entry + for entry in new_state.history + if entry.get("action") == "keep_hand" and entry.get("player") == 0 + ] + assert len(keep_actions) == 1 + + +class TestConcedeValidator: + """Test ConcedeValidator.""" + + def test_valid_concede(self): + """Test valid concession during active game.""" + game_state = create_empty_game_state() + # Game is not over by default + + action = Action( + action_type=ActionType.CONCEDE, + player_id=0, + ) + + validator = ConcedeValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_concede_game_over(self): + """Test concession when game is already over.""" + game_state = create_empty_game_state() + game_state.players[0].life = 0 # Game is over + + action = Action( + action_type=ActionType.CONCEDE, + player_id=0, + ) + + validator = ConcedeValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_concede_invalid_player(self): + """Test concession by invalid player.""" + game_state = create_empty_game_state() + + action = Action( + action_type=ActionType.CONCEDE, + player_id=5, # Invalid player ID + ) + + validator = ConcedeValidator() + assert validator.validate(action, game_state) is False + + +class TestConcedeExecutor: + """Test ConcedeExecutor.""" + + def test_execute_concede(self): + """Test executing concession.""" + game_state = create_empty_game_state() + game_state.players[0].life = 20 + game_state.players[1].life = 15 + + action = Action( + action_type=ActionType.CONCEDE, + player_id=0, + ) + + executor = ConcedeExecutor() + new_state = executor.execute(action, game_state) + + # Player 0 should have 0 life (conceded) + assert new_state.players[0].life == 0 + + # Game should be over with player 1 as winner + assert new_state.is_game_over() is True + assert new_state.winner() == 1 + + +class TestDiscardValidator: + """Test DiscardValidator.""" + + def test_valid_discard(self): + """Test valid card discard.""" + game_state = create_empty_game_state() + + # Add card to hand + card = Card(name="Lightning Bolt", card_type="Instant") + game_state.players[0].hand.add_card(card) + + action = Action( + action_type=ActionType.DISCARD, + player_id=0, + card=card, + ) + + validator = DiscardValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_discard_no_card(self): + """Test discard without specifying card.""" + game_state = create_empty_game_state() + + action = Action( + action_type=ActionType.DISCARD, + player_id=0, + card=None, # No card specified + ) + + validator = DiscardValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_discard_not_in_hand(self): + """Test discard of card not in hand.""" + game_state = create_empty_game_state() + + # Card is not in hand + card = Card(name="Lightning Bolt", card_type="Instant") + + action = Action( + action_type=ActionType.DISCARD, + player_id=0, + card=card, + ) + + validator = DiscardValidator() + assert validator.validate(action, game_state) is False + + +class TestDiscardExecutor: + """Test DiscardExecutor.""" + + def test_execute_discard(self): + """Test executing card discard.""" + game_state = create_empty_game_state() + + # Add card to hand + card = Card(name="Lightning Bolt", card_type="Instant") + game_state.players[0].hand.add_card(card) + + action = Action( + action_type=ActionType.DISCARD, + player_id=0, + card=card, + ) + + executor = DiscardExecutor() + new_state = executor.execute(action, game_state) + + # Card should be in graveyard, not in hand + assert len(new_state.players[0].hand.cards) == 0 + assert len(new_state.players[0].graveyard.cards) == 1 + assert new_state.players[0].graveyard.cards[0].name == "Lightning Bolt" + assert new_state.players[0].graveyard.cards[0].zone == "graveyard" + + +class TestSacrificeValidator: + """Test SacrificeValidator.""" + + def test_valid_sacrifice(self): + """Test valid permanent sacrifice.""" + game_state = create_empty_game_state() + + # Add permanent to battlefield + permanent = Card(name="Grizzly Bears", card_type="Creature") + game_state.players[0].battlefield.add_card(permanent) + + action = Action( + action_type=ActionType.SACRIFICE, + player_id=0, + card=permanent, + ) + + validator = SacrificeValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_sacrifice_no_card(self): + """Test sacrifice without specifying card.""" + game_state = create_empty_game_state() + + action = Action( + action_type=ActionType.SACRIFICE, + player_id=0, + card=None, # No card specified + ) + + validator = SacrificeValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_sacrifice_not_on_battlefield(self): + """Test sacrifice of card not on battlefield.""" + game_state = create_empty_game_state() + + # Card is in hand, not battlefield + card = Card(name="Grizzly Bears", card_type="Creature") + game_state.players[0].hand.add_card(card) + + action = Action( + action_type=ActionType.SACRIFICE, + player_id=0, + card=card, + ) + + validator = SacrificeValidator() + assert validator.validate(action, game_state) is False + + +class TestSacrificeExecutor: + """Test SacrificeExecutor.""" + + def test_execute_sacrifice(self): + """Test executing permanent sacrifice.""" + game_state = create_empty_game_state() + + # Add permanent to battlefield + permanent = Card(name="Grizzly Bears", card_type="Creature") + permanent.attacking = True # Set some combat state + permanent.tapped = True + game_state.players[0].battlefield.add_card(permanent) + + action = Action( + action_type=ActionType.SACRIFICE, + player_id=0, + card=permanent, + ) + + executor = SacrificeExecutor() + new_state = executor.execute(action, game_state) + + # Permanent should be in graveyard, not battlefield + assert len(new_state.players[0].battlefield.cards) == 0 + assert len(new_state.players[0].graveyard.cards) == 1 + graveyard_card = new_state.players[0].graveyard.cards[0] + assert graveyard_card.name == "Grizzly Bears" + assert graveyard_card.zone == "graveyard" + + # Combat states should be reset + assert graveyard_card.attacking is False + assert graveyard_card.tapped is False + + +class TestDestroyValidator: + """Test DestroyValidator.""" + + def test_valid_destroy(self): + """Test valid permanent destruction.""" + game_state = create_empty_game_state() + + # Add permanent to battlefield + permanent = Card(name="Grizzly Bears", card_type="Creature") + game_state.players[0].battlefield.add_card(permanent) + + action = Action( + action_type=ActionType.DESTROY, + player_id=1, # Other player destroying it + card=permanent, + ) + + validator = DestroyValidator() + assert validator.validate(action, game_state) is True + + def test_valid_destroy_opponent_permanent(self): + """Test destroying opponent's permanent.""" + game_state = create_empty_game_state() + + # Add permanent to opponent's battlefield + permanent = Card(name="Grizzly Bears", card_type="Creature") + game_state.players[1].battlefield.add_card(permanent) + + action = Action( + action_type=ActionType.DESTROY, + player_id=0, + card=permanent, + ) + + validator = DestroyValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_destroy_no_card(self): + """Test destruction without specifying card.""" + game_state = create_empty_game_state() + + action = Action( + action_type=ActionType.DESTROY, + player_id=0, + card=None, # No card specified + ) + + validator = DestroyValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_destroy_not_permanent(self): + """Test destruction of card not on battlefield.""" + game_state = create_empty_game_state() + + # Card is in hand, not battlefield + card = Card(name="Lightning Bolt", card_type="Instant") + game_state.players[0].hand.add_card(card) + + action = Action( + action_type=ActionType.DESTROY, + player_id=0, + card=card, + ) + + validator = DestroyValidator() + assert validator.validate(action, game_state) is False + + +class TestDestroyExecutor: + """Test DestroyExecutor.""" + + def test_execute_destroy_own_permanent(self): + """Test executing destruction of own permanent.""" + game_state = create_empty_game_state() + + # Add permanent to battlefield + permanent = Card(name="Grizzly Bears", card_type="Creature") + permanent.attacking = True + permanent.tapped = True + game_state.players[0].battlefield.add_card(permanent) + + action = Action( + action_type=ActionType.DESTROY, + player_id=0, + card=permanent, + ) + + executor = DestroyExecutor() + new_state = executor.execute(action, game_state) + + # Permanent should be in owner's graveyard + assert len(new_state.players[0].battlefield.cards) == 0 + assert len(new_state.players[0].graveyard.cards) == 1 + graveyard_card = new_state.players[0].graveyard.cards[0] + assert graveyard_card.name == "Grizzly Bears" + assert graveyard_card.zone == "graveyard" + + # Combat states should be reset + assert graveyard_card.attacking is False + assert graveyard_card.tapped is False + + def test_execute_destroy_opponent_permanent(self): + """Test executing destruction of opponent's permanent.""" + game_state = create_empty_game_state() + + # Add permanent to opponent's battlefield + permanent = Card(name="Grizzly Bears", card_type="Creature") + game_state.players[1].battlefield.add_card(permanent) + + action = Action( + action_type=ActionType.DESTROY, + player_id=0, + card=permanent, + ) + + executor = DestroyExecutor() + new_state = executor.execute(action, game_state) + + # Permanent should be in owner's (player 1) graveyard + assert len(new_state.players[1].battlefield.cards) == 0 + assert len(new_state.players[1].graveyard.cards) == 1 + assert new_state.players[1].graveyard.cards[0].name == "Grizzly Bears" + + +class TestExileValidator: + """Test ExileValidator.""" + + def test_valid_exile_from_battlefield(self): + """Test valid exile from battlefield.""" + game_state = create_empty_game_state() + + # Add card to battlefield + card = Card(name="Grizzly Bears", card_type="Creature") + game_state.players[0].battlefield.add_card(card) + + action = Action( + action_type=ActionType.EXILE, + player_id=0, + card=card, + ) + + validator = ExileValidator() + assert validator.validate(action, game_state) is True + + def test_valid_exile_from_hand(self): + """Test valid exile from hand.""" + game_state = create_empty_game_state() + + # Add card to hand + card = Card(name="Lightning Bolt", card_type="Instant") + game_state.players[0].hand.add_card(card) + + action = Action( + action_type=ActionType.EXILE, + player_id=0, + card=card, + ) + + validator = ExileValidator() + assert validator.validate(action, game_state) is True + + def test_valid_exile_from_graveyard(self): + """Test valid exile from graveyard.""" + game_state = create_empty_game_state() + + # Add card to graveyard + card = Card(name="Lightning Bolt", card_type="Instant") + game_state.players[0].graveyard.add_card(card) + + action = Action( + action_type=ActionType.EXILE, + player_id=0, + card=card, + ) + + validator = ExileValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_exile_no_card(self): + """Test exile without specifying card.""" + game_state = create_empty_game_state() + + action = Action( + action_type=ActionType.EXILE, + player_id=0, + card=None, # No card specified + ) + + validator = ExileValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_exile_card_not_found(self): + """Test exile of card not in any zone.""" + game_state = create_empty_game_state() + + # Card is not in any zone + card = Card(name="Lightning Bolt", card_type="Instant") + + action = Action( + action_type=ActionType.EXILE, + player_id=0, + card=card, + ) + + validator = ExileValidator() + assert validator.validate(action, game_state) is False + + +class TestExileExecutor: + """Test ExileExecutor.""" + + def test_execute_exile_from_battlefield(self): + """Test executing exile from battlefield.""" + game_state = create_empty_game_state() + + # Add card to battlefield + card = Card(name="Grizzly Bears", card_type="Creature") + card.attacking = True + card.tapped = True + game_state.players[0].battlefield.add_card(card) + + action = Action( + action_type=ActionType.EXILE, + player_id=0, + card=card, + ) + + executor = ExileExecutor() + new_state = executor.execute(action, game_state) + + # Card should be in exile, not battlefield + assert len(new_state.players[0].battlefield.cards) == 0 + assert len(new_state.players[0].exile.cards) == 1 + exiled_card = new_state.players[0].exile.cards[0] + assert exiled_card.name == "Grizzly Bears" + assert exiled_card.zone == "exile" + + # Combat states should be reset + assert exiled_card.attacking is False + assert exiled_card.blocking is None + assert exiled_card.tapped is False + + def test_execute_exile_from_hand(self): + """Test executing exile from hand.""" + game_state = create_empty_game_state() + + # Add card to hand + card = Card(name="Lightning Bolt", card_type="Instant") + game_state.players[0].hand.add_card(card) + + action = Action( + action_type=ActionType.EXILE, + player_id=0, + card=card, + ) + + executor = ExileExecutor() + new_state = executor.execute(action, game_state) + + # Card should be in exile, not hand + assert len(new_state.players[0].hand.cards) == 0 + assert len(new_state.players[0].exile.cards) == 1 + assert new_state.players[0].exile.cards[0].name == "Lightning Bolt" + assert new_state.players[0].exile.cards[0].zone == "exile" + + def test_execute_exile_from_graveyard(self): + """Test executing exile from graveyard.""" + game_state = create_empty_game_state() + + # Add card to graveyard + card = Card(name="Lightning Bolt", card_type="Instant") + game_state.players[0].graveyard.add_card(card) + + action = Action( + action_type=ActionType.EXILE, + player_id=0, + card=card, + ) + + executor = ExileExecutor() + new_state = executor.execute(action, game_state) + + # Card should be in exile, not graveyard + assert len(new_state.players[0].graveyard.cards) == 0 + assert len(new_state.players[0].exile.cards) == 1 + assert new_state.players[0].exile.cards[0].name == "Lightning Bolt" + assert new_state.players[0].exile.cards[0].zone == "exile" diff --git a/tests/test_combat_actions.py b/tests/test_combat_actions.py new file mode 100644 index 0000000..5e8c245 --- /dev/null +++ b/tests/test_combat_actions.py @@ -0,0 +1,767 @@ +"""Tests for combat-related action validators and executors.""" + +from manamind.core.action import ( + Action, + ActionType, + AssignCombatDamageExecutor, + AssignCombatDamageValidator, + DeclareAttackersExecutor, + DeclareAttackersValidator, + DeclareBlockersExecutor, + DeclareBlockersValidator, + OrderBlockersExecutor, + OrderBlockersValidator, +) +from manamind.core.game_state import Card, create_empty_game_state + + +class TestDeclareAttackersValidator: + """Test DeclareAttackersValidator.""" + + def test_valid_attacker_declaration(self): + """Test valid attacker declaration during combat phase.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 0 + + # Create an attacking creature + creature = Card( + name="Grizzly Bears", + card_type="Creature", + power=2, + toughness=2, + instance_id=1, + keywords=[], + ) + creature.tapped = False + creature.summoning_sick = False + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.DECLARE_ATTACKERS, + player_id=0, + attackers=[1], + ) + + validator = DeclareAttackersValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_not_combat_phase(self): + """Test attacker declaration outside combat phase.""" + game_state = create_empty_game_state() + game_state.phase = "main" # Not combat phase + + action = Action( + action_type=ActionType.DECLARE_ATTACKERS, + player_id=0, + attackers=[1], + ) + + validator = DeclareAttackersValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_not_active_player(self): + """Test attacker declaration by non-active player.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 1 # Other player is active + game_state.priority_player = 0 + + action = Action( + action_type=ActionType.DECLARE_ATTACKERS, + player_id=0, + attackers=[1], + ) + + validator = DeclareAttackersValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_tapped_attacker(self): + """Test declaring tapped creature as attacker.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 0 + + # Create a tapped creature + creature = Card( + name="Grizzly Bears", + card_type="Creature", + power=2, + toughness=2, + instance_id=1, + keywords=[], + ) + creature.tapped = True # Already tapped + creature.summoning_sick = False + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.DECLARE_ATTACKERS, + player_id=0, + attackers=[1], + ) + + validator = DeclareAttackersValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_summoning_sick_attacker(self): + """Test declaring summoning sick creature as attacker.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 0 + + # Create a summoning sick creature + creature = Card( + name="Grizzly Bears", + card_type="Creature", + power=2, + toughness=2, + instance_id=1, + keywords=[], + ) + creature.tapped = False + creature.summoning_sick = True # Has summoning sickness + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.DECLARE_ATTACKERS, + player_id=0, + attackers=[1], + ) + + validator = DeclareAttackersValidator() + assert validator.validate(action, game_state) is False + + def test_valid_haste_summoning_sick_attacker(self): + """Test declaring summoning sick creature with haste as attacker.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 0 + + # Create a summoning sick creature with haste + creature = Card( + name="Lightning Elemental", + card_type="Creature", + power=4, + toughness=1, + instance_id=1, + keywords=["Haste"], + ) + creature.tapped = False + creature.summoning_sick = True # Has summoning sickness + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.DECLARE_ATTACKERS, + player_id=0, + attackers=[1], + ) + + validator = DeclareAttackersValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_defender_attacker(self): + """Test declaring creature with defender as attacker.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 0 + + # Create a creature with defender + creature = Card( + name="Wall of Stone", + card_type="Creature", + power=0, + toughness=8, + instance_id=1, + keywords=["Defender"], + ) + creature.tapped = False + creature.summoning_sick = False + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.DECLARE_ATTACKERS, + player_id=0, + attackers=[1], + ) + + validator = DeclareAttackersValidator() + assert validator.validate(action, game_state) is False + + +class TestDeclareAttackersExecutor: + """Test DeclareAttackersExecutor.""" + + def test_execute_attacker_declaration(self): + """Test executing attacker declaration.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 0 + + # Create an attacking creature + creature = Card( + name="Grizzly Bears", + card_type="Creature", + power=2, + toughness=2, + instance_id=1, + keywords=[], + ) + creature.tapped = False + creature.summoning_sick = False + creature.attacking = False + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.DECLARE_ATTACKERS, + player_id=0, + attackers=[1], + ) + + executor = DeclareAttackersExecutor() + new_state = executor.execute(action, game_state) + + # Find the creature in the new state + new_creature = None + for card in new_state.players[0].battlefield.cards: + if card.instance_id == 1: + new_creature = card + break + + assert new_creature is not None + assert new_creature.attacking is True + assert new_creature.tapped is True # Attacking creatures become tapped + assert new_state.priority_player == 1 # Priority passes to defender + + def test_execute_multiple_attackers(self): + """Test executing multiple attacker declarations.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 0 + + # Create multiple attacking creatures + for i in range(3): + creature = Card( + name=f"Creature {i}", + card_type="Creature", + power=2, + toughness=2, + instance_id=i + 1, + keywords=[], + ) + creature.tapped = False + creature.summoning_sick = False + creature.attacking = False + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.DECLARE_ATTACKERS, + player_id=0, + attackers=[1, 2, 3], + ) + + executor = DeclareAttackersExecutor() + new_state = executor.execute(action, game_state) + + # Check all creatures are attacking and tapped + attacking_count = 0 + for card in new_state.players[0].battlefield.cards: + if card.instance_id in [1, 2, 3]: + assert card.attacking is True + assert card.tapped is True + attacking_count += 1 + + assert attacking_count == 3 + + +class TestDeclareBlockersValidator: + """Test DeclareBlockersValidator.""" + + def test_valid_blocker_declaration(self): + """Test valid blocker declaration.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 # Player 0 is attacking + game_state.priority_player = 1 # Player 1 has priority (defending) + + # Create attacking creature for player 0 + attacker = Card( + name="Attacker", + card_type="Creature", + power=2, + toughness=2, + instance_id=1, + keywords=[], + ) + attacker.attacking = True + game_state.players[0].battlefield.add_card(attacker) + + # Create blocking creature for player 1 + blocker = Card( + name="Blocker", + card_type="Creature", + power=1, + toughness=3, + instance_id=2, + keywords=[], + ) + blocker.tapped = False + game_state.players[1].battlefield.add_card(blocker) + + action = Action( + action_type=ActionType.DECLARE_BLOCKERS, + player_id=1, + blockers={1: [2]}, # Block attacker 1 with blocker 2 + ) + + validator = DeclareBlockersValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_not_combat_phase(self): + """Test blocker declaration outside combat phase.""" + game_state = create_empty_game_state() + game_state.phase = "main" # Not combat phase + + action = Action( + action_type=ActionType.DECLARE_BLOCKERS, + player_id=1, + blockers={1: [2]}, + ) + + validator = DeclareBlockersValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_active_player_blocking(self): + """Test blocking by the active (attacking) player.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 0 # Active player trying to block + + action = Action( + action_type=ActionType.DECLARE_BLOCKERS, + player_id=0, + blockers={1: [2]}, + ) + + validator = DeclareBlockersValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_tapped_blocker(self): + """Test declaring tapped creature as blocker.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 1 + + # Create attacking creature + attacker = Card( + name="Attacker", + card_type="Creature", + instance_id=1, + keywords=[], + ) + attacker.attacking = True + game_state.players[0].battlefield.add_card(attacker) + + # Create tapped blocker + blocker = Card( + name="Blocker", + card_type="Creature", + instance_id=2, + keywords=[], + ) + blocker.tapped = True # Already tapped + game_state.players[1].battlefield.add_card(blocker) + + action = Action( + action_type=ActionType.DECLARE_BLOCKERS, + player_id=1, + blockers={1: [2]}, + ) + + validator = DeclareBlockersValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_blocking_multiple_attackers(self): + """Test using same creature to block multiple attackers.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 1 + + # Create multiple attacking creatures + for i in range(2): + attacker = Card( + name=f"Attacker {i}", + card_type="Creature", + instance_id=i + 1, + keywords=[], + ) + attacker.attacking = True + game_state.players[0].battlefield.add_card(attacker) + + # Create one blocker + blocker = Card( + name="Blocker", + card_type="Creature", + instance_id=3, + keywords=[], + ) + blocker.tapped = False + game_state.players[1].battlefield.add_card(blocker) + + action = Action( + action_type=ActionType.DECLARE_BLOCKERS, + player_id=1, + blockers={1: [3], 2: [3]}, # Same blocker blocking both attackers + ) + + validator = DeclareBlockersValidator() + assert validator.validate(action, game_state) is False + + +class TestDeclareBlockersExecutor: + """Test DeclareBlockersExecutor.""" + + def test_execute_blocker_declaration(self): + """Test executing blocker declaration.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 1 + + # Create attacking creature + attacker = Card( + name="Attacker", + card_type="Creature", + instance_id=1, + keywords=[], + ) + attacker.attacking = True + attacker.blocked_by = [] + game_state.players[0].battlefield.add_card(attacker) + + # Create blocking creature + blocker = Card( + name="Blocker", + card_type="Creature", + instance_id=2, + keywords=[], + ) + blocker.tapped = False + blocker.blocking = None + game_state.players[1].battlefield.add_card(blocker) + + action = Action( + action_type=ActionType.DECLARE_BLOCKERS, + player_id=1, + blockers={1: [2]}, + ) + + executor = DeclareBlockersExecutor() + new_state = executor.execute(action, game_state) + + # Find the blocker in the new state + new_blocker = None + for card in new_state.players[1].battlefield.cards: + if card.instance_id == 2: + new_blocker = card + break + + # Find the attacker in the new state + new_attacker = None + for card in new_state.players[0].battlefield.cards: + if card.instance_id == 1: + new_attacker = card + break + + assert new_blocker is not None + assert new_blocker.blocking == 1 # Blocking attacker 1 + assert new_attacker is not None + assert 2 in new_attacker.blocked_by # Blocked by blocker 2 + assert new_state.priority_player == 0 # Priority back to attacker + + +class TestAssignCombatDamageValidator: + """Test AssignCombatDamageValidator.""" + + def test_valid_damage_assignment(self): + """Test valid combat damage assignment.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.priority_player = 0 + + # Create attacking creature + attacker = Card( + name="Attacker", + card_type="Creature", + power=3, + toughness=2, + instance_id=1, + keywords=[], + ) + attacker.attacking = True + game_state.players[0].battlefield.add_card(attacker) + + # Create blocking creature + blocker = Card( + name="Blocker", + card_type="Creature", + power=2, + toughness=4, + instance_id=2, + keywords=[], + ) + blocker.blocking = 1 + game_state.players[1].battlefield.add_card(blocker) + + action = Action( + action_type=ActionType.ASSIGN_COMBAT_DAMAGE, + player_id=0, + damage_assignment={ + 1: {2: 3} + }, # Attacker 1 deals 3 damage to blocker 2 + ) + + validator = AssignCombatDamageValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_not_combat_phase(self): + """Test damage assignment outside combat phase.""" + game_state = create_empty_game_state() + game_state.phase = "main" # Not combat phase + + action = Action( + action_type=ActionType.ASSIGN_COMBAT_DAMAGE, + player_id=0, + damage_assignment={1: {2: 3}}, + ) + + validator = AssignCombatDamageValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_excess_damage(self): + """Test assigning more damage than creature's power.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.priority_player = 0 + + # Create attacking creature with power 2 + attacker = Card( + name="Attacker", + card_type="Creature", + power=2, # Only has 2 power + toughness=2, + instance_id=1, + keywords=[], + ) + attacker.attacking = True + game_state.players[0].battlefield.add_card(attacker) + + action = Action( + action_type=ActionType.ASSIGN_COMBAT_DAMAGE, + player_id=0, + damage_assignment={ + 1: {2: 5} + }, # Trying to assign 5 damage with 2 power + ) + + validator = AssignCombatDamageValidator() + assert validator.validate(action, game_state) is False + + +class TestAssignCombatDamageExecutor: + """Test AssignCombatDamageExecutor.""" + + def test_execute_damage_assignment(self): + """Test executing combat damage assignment.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.priority_player = 0 + + # Create attacking creature + attacker = Card( + name="Attacker", + card_type="Creature", + power=3, + toughness=2, + instance_id=1, + keywords=[], + ) + attacker.attacking = True + game_state.players[0].battlefield.add_card(attacker) + + # Create blocking creature + blocker = Card( + name="Blocker", + card_type="Creature", + power=2, + toughness=2, # Will die from 3 damage + instance_id=2, + keywords=[], + counters={"damage": 0}, + ) + blocker.blocking = 1 + game_state.players[1].battlefield.add_card(blocker) + + action = Action( + action_type=ActionType.ASSIGN_COMBAT_DAMAGE, + player_id=0, + damage_assignment={1: {2: 3}}, + ) + + executor = AssignCombatDamageExecutor() + new_state = executor.execute(action, game_state) + + # Blocker should be in graveyard (3 damage >= 2 toughness) + assert len(new_state.players[1].graveyard.cards) == 1 + assert new_state.players[1].graveyard.cards[0].name == "Blocker" + + # Attacker should no longer be attacking + new_attacker = None + for card in new_state.players[0].battlefield.cards: + if card.instance_id == 1: + new_attacker = card + break + + assert new_attacker is not None + assert new_attacker.attacking is False + + def test_execute_damage_to_player(self): + """Test executing combat damage assignment to player.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.priority_player = 0 + game_state.players[1].life = 20 + + # Create attacking creature + attacker = Card( + name="Attacker", + card_type="Creature", + power=5, + toughness=2, + instance_id=10, # Different ID to avoid conflict + keywords=[], + ) + attacker.attacking = True + game_state.players[0].battlefield.add_card(attacker) + + action = Action( + action_type=ActionType.ASSIGN_COMBAT_DAMAGE, + player_id=0, + damage_assignment={ + 10: {1: 5} + }, # Attacker 10 deals 5 damage to player 1 (target_id=1) + ) + + executor = AssignCombatDamageExecutor() + new_state = executor.execute(action, game_state) + + # Player should take damage + assert new_state.players[1].life == 15 # 20 - 5 = 15 + + +class TestOrderBlockersValidator: + """Test OrderBlockersValidator.""" + + def test_valid_blocker_ordering(self): + """Test valid blocker ordering.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 0 + + # Create attacking creature + attacker = Card( + name="Attacker", + card_type="Creature", + instance_id=1, + keywords=[], + ) + attacker.attacking = True + game_state.players[0].battlefield.add_card(attacker) + + # Create multiple blocking creatures + for i in range(2): + blocker = Card( + name=f"Blocker {i}", + card_type="Creature", + instance_id=i + 2, + keywords=[], + ) + blocker.blocking = 1 # Both blocking attacker 1 + game_state.players[1].battlefield.add_card(blocker) + + action = Action( + action_type=ActionType.ORDER_BLOCKERS, + player_id=0, + additional_choices={"1": [2, 3]}, # Order blockers for attacker 1 + ) + + validator = OrderBlockersValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_not_combat_phase(self): + """Test blocker ordering outside combat phase.""" + game_state = create_empty_game_state() + game_state.phase = "main" # Not combat phase + + action = Action( + action_type=ActionType.ORDER_BLOCKERS, + player_id=0, + additional_choices={"1": [2, 3]}, + ) + + validator = OrderBlockersValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_not_active_player(self): + """Test blocker ordering by non-active player.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 1 # Other player is active + game_state.priority_player = 0 + + action = Action( + action_type=ActionType.ORDER_BLOCKERS, + player_id=0, + additional_choices={"1": [2, 3]}, + ) + + validator = OrderBlockersValidator() + assert validator.validate(action, game_state) is False + + +class TestOrderBlockersExecutor: + """Test OrderBlockersExecutor.""" + + def test_execute_blocker_ordering(self): + """Test executing blocker ordering.""" + game_state = create_empty_game_state() + game_state.phase = "combat" + game_state.active_player = 0 + game_state.priority_player = 0 + + action = Action( + action_type=ActionType.ORDER_BLOCKERS, + player_id=0, + additional_choices={"1": [2, 3]}, # Order blockers for attacker 1 + ) + + executor = OrderBlockersExecutor() + new_state = executor.execute(action, game_state) + + # Check that order was recorded in history + assert len(new_state.history) >= 1 + last_action = new_state.history[-1] + assert last_action["action"] == "order_blockers" + assert last_action["player"] == 0 + assert last_action["blocker_orders"] == {1: [2, 3]} diff --git a/tests/test_targeting_actions.py b/tests/test_targeting_actions.py new file mode 100644 index 0000000..4f0047e --- /dev/null +++ b/tests/test_targeting_actions.py @@ -0,0 +1,1076 @@ +"""Tests for targeting and choice action validators and executors.""" + +from manamind.core.action import ( + Action, + ActionType, + ChooseModeExecutor, + ChooseModeValidator, + ChooseTargetExecutor, + ChooseTargetValidator, + ChooseXValueExecutor, + ChooseXValueValidator, + PayManaExecutor, + PayManaValidator, + TapForManaExecutor, + TapForManaValidator, +) +from manamind.core.game_state import Card, create_empty_game_state + + +class TestChooseTargetValidator: + """Test ChooseTargetValidator.""" + + def test_valid_target_selection_card(self): + """Test valid card target selection.""" + game_state = create_empty_game_state() + + # Add target card to battlefield + target_card = Card(name="Grizzly Bears", card_type="Creature") + game_state.players[1].battlefield.add_card(target_card) + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_cards=[target_card], + ) + + validator = ChooseTargetValidator() + assert validator.validate(action, game_state) is True + + def test_valid_target_selection_player(self): + """Test valid player target selection.""" + game_state = create_empty_game_state() + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_players=[1], # Target opponent + ) + + validator = ChooseTargetValidator() + assert validator.validate(action, game_state) is True + + def test_valid_multiple_targets(self): + """Test valid multiple target selection.""" + game_state = create_empty_game_state() + + # Add multiple target cards + target1 = Card(name="Grizzly Bears", card_type="Creature") + target2 = Card(name="Lightning Bolt", card_type="Instant") + game_state.players[1].battlefield.add_card(target1) + game_state.players[1].hand.add_card(target2) + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_cards=[target1, target2], + ) + + validator = ChooseTargetValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_no_targets(self): + """Test target selection with no targets.""" + game_state = create_empty_game_state() + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_cards=[], + target_players=[], + ) + + validator = ChooseTargetValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_target_not_found(self): + """Test targeting card not in game state.""" + game_state = create_empty_game_state() + + # Card not in any zone + target_card = Card(name="Grizzly Bears", card_type="Creature") + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_cards=[target_card], + ) + + validator = ChooseTargetValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_target_hexproof(self): + """Test targeting hexproof creature.""" + game_state = create_empty_game_state() + + # Add hexproof creature + hexproof_creature = Card( + name="Hexproof Bear", card_type="Creature", keywords=["Hexproof"] + ) + game_state.players[1].battlefield.add_card(hexproof_creature) + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_cards=[hexproof_creature], + ) + + validator = ChooseTargetValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_target_shroud(self): + """Test targeting creature with shroud.""" + game_state = create_empty_game_state() + + # Add creature with shroud + shroud_creature = Card( + name="Shroud Bear", card_type="Creature", keywords=["Shroud"] + ) + game_state.players[1].battlefield.add_card(shroud_creature) + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_cards=[shroud_creature], + ) + + validator = ChooseTargetValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_target_protection(self): + """Test targeting creature with protection.""" + game_state = create_empty_game_state() + + # Add creature with protection + protected_creature = Card( + name="Protected Bear", + card_type="Creature", + keywords=["Protection from red"], + ) + game_state.players[1].battlefield.add_card(protected_creature) + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_cards=[protected_creature], + ) + + validator = ChooseTargetValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_player_target_out_of_range(self): + """Test targeting invalid player ID.""" + game_state = create_empty_game_state() + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_players=[5], # Invalid player ID + ) + + validator = ChooseTargetValidator() + assert validator.validate(action, game_state) is False + + def test_valid_targeting_requirements(self): + """Test valid targeting with requirements.""" + game_state = create_empty_game_state() + + # Add creature target + target_creature = Card(name="Grizzly Bears", card_type="Creature") + game_state.players[1].battlefield.add_card(target_creature) + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_cards=[target_creature], + additional_choices={ + "targeting_requirements": {"count": 1, "type": "creature"} + }, + ) + + validator = ChooseTargetValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_targeting_requirements_wrong_type(self): + """Test invalid targeting with wrong target type.""" + game_state = create_empty_game_state() + + # Add non-creature target + target_card = Card(name="Lightning Bolt", card_type="Instant") + game_state.players[1].hand.add_card(target_card) + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_cards=[target_card], + additional_choices={ + "targeting_requirements": { + "count": 1, + "type": "creature", # Requires creature + } + }, + ) + + validator = ChooseTargetValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_targeting_requirements_wrong_count(self): + """Test invalid targeting with wrong target count.""" + game_state = create_empty_game_state() + + # Add creature target + target_creature = Card(name="Grizzly Bears", card_type="Creature") + game_state.players[1].battlefield.add_card(target_creature) + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_cards=[target_creature], + additional_choices={ + "targeting_requirements": { + "count": 2, # Requires 2 targets, but only providing 1 + "type": "creature", + } + }, + ) + + validator = ChooseTargetValidator() + assert validator.validate(action, game_state) is False + + +class TestChooseTargetExecutor: + """Test ChooseTargetExecutor.""" + + def test_execute_target_selection(self): + """Test executing target selection.""" + game_state = create_empty_game_state() + + # Add target card + target_card = Card(name="Grizzly Bears", card_type="Creature") + game_state.players[1].battlefield.add_card(target_card) + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_cards=[target_card], + target_players=[1], + ) + + executor = ChooseTargetExecutor() + new_state = executor.execute(action, game_state) + + # Check that targets were stored + assert hasattr(new_state, "pending_targets") + assert target_card in new_state.pending_targets["cards"] + assert 1 in new_state.pending_targets["players"] + + def test_execute_multiple_target_selection(self): + """Test executing multiple target selection.""" + game_state = create_empty_game_state() + + # Add multiple targets + target1 = Card(name="Grizzly Bears", card_type="Creature") + target2 = Card(name="Hill Giant", card_type="Creature") + game_state.players[1].battlefield.add_card(target1) + game_state.players[1].battlefield.add_card(target2) + + action = Action( + action_type=ActionType.CHOOSE_TARGET, + player_id=0, + target_cards=[target1, target2], + ) + + executor = ChooseTargetExecutor() + new_state = executor.execute(action, game_state) + + # Check that all targets were stored + assert len(new_state.pending_targets["cards"]) == 2 + assert target1 in new_state.pending_targets["cards"] + assert target2 in new_state.pending_targets["cards"] + + +class TestChooseModeValidator: + """Test ChooseModeValidator.""" + + def test_valid_single_mode_selection(self): + """Test valid single mode selection.""" + game_state = create_empty_game_state() + + # Create modal spell + modal_spell = Card( + name="Charms", + card_type="Instant", + oracle_text="Choose one — • Deal 2 damage. • Draw a card.", + ) + + action = Action( + action_type=ActionType.CHOOSE_MODE, + player_id=0, + card=modal_spell, + modes_chosen=["mode_1"], + ) + + validator = ChooseModeValidator() + assert validator.validate(action, game_state) is True + + def test_valid_multiple_mode_selection(self): + """Test valid multiple mode selection.""" + game_state = create_empty_game_state() + + # Create modal spell that allows multiple modes + modal_spell = Card( + name="Cryptic Command", + card_type="Instant", + oracle_text=( + "Choose two — • Counter spell. • Return permanent. " + "• Tap creatures. • Draw a card." + ), + ) + + action = Action( + action_type=ActionType.CHOOSE_MODE, + player_id=0, + card=modal_spell, + modes_chosen=["mode_1", "mode_2"], + ) + + validator = ChooseModeValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_no_modes_chosen(self): + """Test mode selection with no modes chosen.""" + game_state = create_empty_game_state() + + modal_spell = Card( + name="Charms", + card_type="Instant", + oracle_text="Choose one — • Deal 2 damage. • Draw a card.", + ) + + action = Action( + action_type=ActionType.CHOOSE_MODE, + player_id=0, + card=modal_spell, + modes_chosen=[], # No modes chosen + ) + + validator = ChooseModeValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_no_card(self): + """Test mode selection without card.""" + game_state = create_empty_game_state() + + action = Action( + action_type=ActionType.CHOOSE_MODE, + player_id=0, + card=None, # No card + modes_chosen=["mode_1"], + ) + + validator = ChooseModeValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_mode_not_available(self): + """Test selecting unavailable mode.""" + game_state = create_empty_game_state() + + # Create modal spell with only 2 modes + modal_spell = Card( + name="Charms", + card_type="Instant", + oracle_text="Choose one — • Deal 2 damage. • Draw a card.", + ) + + action = Action( + action_type=ActionType.CHOOSE_MODE, + player_id=0, + card=modal_spell, + modes_chosen=["mode_5"], # Mode doesn't exist + ) + + validator = ChooseModeValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_too_many_modes_choose_one(self): + """Test selecting too many modes for 'choose one' spell.""" + game_state = create_empty_game_state() + + modal_spell = Card( + name="Charms", + card_type="Instant", + oracle_text="Choose one — • Deal 2 damage. • Draw a card.", + ) + + action = Action( + action_type=ActionType.CHOOSE_MODE, + player_id=0, + card=modal_spell, + modes_chosen=["mode_1", "mode_2"], # Too many for 'choose one' + ) + + validator = ChooseModeValidator() + assert validator.validate(action, game_state) is False + + +class TestChooseModeExecutor: + """Test ChooseModeExecutor.""" + + def test_execute_mode_selection(self): + """Test executing mode selection.""" + game_state = create_empty_game_state() + + modal_spell = Card( + name="Charms", + card_type="Instant", + oracle_text="Choose one — • Deal 2 damage. • Draw a card.", + ) + + action = Action( + action_type=ActionType.CHOOSE_MODE, + player_id=0, + card=modal_spell, + modes_chosen=["mode_1"], + ) + + executor = ChooseModeExecutor() + new_state = executor.execute(action, game_state) + + # Check that modes were stored + assert hasattr(new_state, "pending_modes") + assert new_state.pending_modes == ["mode_1"] + + def test_execute_multiple_mode_selection(self): + """Test executing multiple mode selection.""" + game_state = create_empty_game_state() + + modal_spell = Card( + name="Cryptic Command", + card_type="Instant", + oracle_text=( + "Choose two — • Counter spell. • Return permanent. " + "• Tap creatures. • Draw a card." + ), + ) + + action = Action( + action_type=ActionType.CHOOSE_MODE, + player_id=0, + card=modal_spell, + modes_chosen=["mode_1", "mode_3"], + ) + + executor = ChooseModeExecutor() + new_state = executor.execute(action, game_state) + + # Check that all modes were stored + assert new_state.pending_modes == ["mode_1", "mode_3"] + + +class TestChooseXValueValidator: + """Test ChooseXValueValidator.""" + + def test_valid_x_value_selection(self): + """Test valid X value selection.""" + game_state = create_empty_game_state() + game_state.players[0].mana_pool = {"colorless": 5} + + # Create X spell + x_spell = Card( + name="Fireball", + card_type="Sorcery", + mana_cost="{X}{R}", + oracle_text="Deal X damage to any target.", + ) + + action = Action( + action_type=ActionType.CHOOSE_X_VALUE, + player_id=0, + card=x_spell, + x_value=3, + ) + + validator = ChooseXValueValidator() + assert validator.validate(action, game_state) is True + + def test_valid_x_value_zero(self): + """Test valid X value of zero.""" + game_state = create_empty_game_state() + game_state.players[0].mana_pool = {"R": 1} + + x_spell = Card( + name="Fireball", + card_type="Sorcery", + mana_cost="{X}{R}", + oracle_text="Deal X damage to any target.", + ) + + action = Action( + action_type=ActionType.CHOOSE_X_VALUE, + player_id=0, + card=x_spell, + x_value=0, + ) + + validator = ChooseXValueValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_x_value_none(self): + """Test X value selection with None value.""" + game_state = create_empty_game_state() + + x_spell = Card( + name="Fireball", card_type="Sorcery", mana_cost="{X}{R}" + ) + + action = Action( + action_type=ActionType.CHOOSE_X_VALUE, + player_id=0, + card=x_spell, + x_value=None, # No value provided + ) + + validator = ChooseXValueValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_negative_x_value(self): + """Test negative X value selection.""" + game_state = create_empty_game_state() + + x_spell = Card( + name="Fireball", card_type="Sorcery", mana_cost="{X}{R}" + ) + + action = Action( + action_type=ActionType.CHOOSE_X_VALUE, + player_id=0, + card=x_spell, + x_value=-1, # Negative value + ) + + validator = ChooseXValueValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_no_card(self): + """Test X value selection without card.""" + game_state = create_empty_game_state() + + action = Action( + action_type=ActionType.CHOOSE_X_VALUE, + player_id=0, + card=None, # No card + x_value=3, + ) + + validator = ChooseXValueValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_no_x_in_cost(self): + """Test X value selection for non-X spell.""" + game_state = create_empty_game_state() + + non_x_spell = Card( + name="Lightning Bolt", + card_type="Instant", + mana_cost="{R}", # No X in cost + ) + + action = Action( + action_type=ActionType.CHOOSE_X_VALUE, + player_id=0, + card=non_x_spell, + x_value=3, + ) + + validator = ChooseXValueValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_insufficient_mana(self): + """Test X value selection with insufficient mana.""" + game_state = create_empty_game_state() + game_state.players[0].mana_pool = {"R": 1} # Only 1 red mana + + x_spell = Card( + name="Fireball", card_type="Sorcery", mana_cost="{X}{R}" + ) + + action = Action( + action_type=ActionType.CHOOSE_X_VALUE, + player_id=0, + card=x_spell, + x_value=5, # Needs 5 + 1 = 6 mana, but only have 1 + ) + + validator = ChooseXValueValidator() + assert validator.validate(action, game_state) is False + + +class TestChooseXValueExecutor: + """Test ChooseXValueExecutor.""" + + def test_execute_x_value_selection(self): + """Test executing X value selection.""" + game_state = create_empty_game_state() + + x_spell = Card( + name="Fireball", card_type="Sorcery", mana_cost="{X}{R}" + ) + + action = Action( + action_type=ActionType.CHOOSE_X_VALUE, + player_id=0, + card=x_spell, + x_value=3, + ) + + executor = ChooseXValueExecutor() + new_state = executor.execute(action, game_state) + + # Check that X value was stored + assert hasattr(new_state, "pending_x_value") + assert new_state.pending_x_value == 3 + + def test_execute_x_value_zero(self): + """Test executing X value selection with zero.""" + game_state = create_empty_game_state() + + x_spell = Card( + name="Fireball", card_type="Sorcery", mana_cost="{X}{R}" + ) + + action = Action( + action_type=ActionType.CHOOSE_X_VALUE, + player_id=0, + card=x_spell, + x_value=0, + ) + + executor = ChooseXValueExecutor() + new_state = executor.execute(action, game_state) + + assert new_state.pending_x_value == 0 + + +class TestTapForManaValidator: + """Test TapForManaValidator.""" + + def test_valid_tap_basic_land(self): + """Test valid tapping basic land for mana.""" + game_state = create_empty_game_state() + + land = Card(name="Mountain", card_type="Land", instance_id=1) + land.tapped = False + game_state.players[0].battlefield.add_card(land) + + action = Action( + action_type=ActionType.TAP_FOR_MANA, + player_id=0, + card=land, + ) + + validator = TapForManaValidator() + assert validator.validate(action, game_state) is True + + def test_valid_tap_mana_creature(self): + """Test valid tapping mana-producing creature.""" + game_state = create_empty_game_state() + + mana_dork = Card( + name="Llanowar Elves", + card_type="Creature", + oracle_text="{T}: Add {G}.", + instance_id=1, + ) + mana_dork.tapped = False + game_state.players[0].battlefield.add_card(mana_dork) + + action = Action( + action_type=ActionType.TAP_FOR_MANA, + player_id=0, + card=mana_dork, + additional_choices={"mana_ability": "{T}: Add {G}."}, + ) + + validator = TapForManaValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_no_card(self): + """Test tapping for mana without card.""" + game_state = create_empty_game_state() + + action = Action( + action_type=ActionType.TAP_FOR_MANA, + player_id=0, + card=None, # No card + ) + + validator = TapForManaValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_card_not_found(self): + """Test tapping card not on battlefield.""" + game_state = create_empty_game_state() + + land = Card(name="Mountain", card_type="Land", instance_id=1) + # Card not added to battlefield + + action = Action( + action_type=ActionType.TAP_FOR_MANA, + player_id=0, + card=land, + ) + + validator = TapForManaValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_already_tapped(self): + """Test tapping already tapped permanent.""" + game_state = create_empty_game_state() + + land = Card(name="Mountain", card_type="Land", instance_id=1) + land.tapped = True # Already tapped + game_state.players[0].battlefield.add_card(land) + + action = Action( + action_type=ActionType.TAP_FOR_MANA, + player_id=0, + card=land, + ) + + validator = TapForManaValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_no_mana_ability(self): + """Test tapping permanent that can't produce mana.""" + game_state = create_empty_game_state() + + creature = Card( + name="Grizzly Bears", + card_type="Creature", + oracle_text="", # No mana ability + instance_id=1, + ) + creature.tapped = False + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.TAP_FOR_MANA, + player_id=0, + card=creature, + ) + + validator = TapForManaValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_mana_ability_not_on_card(self): + """Test tapping with specified ability not on card.""" + game_state = create_empty_game_state() + + creature = Card( + name="Grizzly Bears", + card_type="Creature", + oracle_text="", # No abilities + instance_id=1, + ) + creature.tapped = False + game_state.players[0].battlefield.add_card(creature) + + action = Action( + action_type=ActionType.TAP_FOR_MANA, + player_id=0, + card=creature, + additional_choices={ + "mana_ability": "{T}: Add {G}." + }, # Ability not on card + ) + + validator = TapForManaValidator() + assert validator.validate(action, game_state) is False + + +class TestTapForManaExecutor: + """Test TapForManaExecutor.""" + + def test_execute_tap_basic_land(self): + """Test executing tap basic land for mana.""" + game_state = create_empty_game_state() + + land = Card(name="Mountain", card_type="Land", instance_id=1) + land.tapped = False + game_state.players[0].battlefield.add_card(land) + + action = Action( + action_type=ActionType.TAP_FOR_MANA, + player_id=0, + card=land, + ) + + executor = TapForManaExecutor() + new_state = executor.execute(action, game_state) + + # Find land in new state + new_land = None + for card in new_state.players[0].battlefield.cards: + if card.instance_id == 1: + new_land = card + break + + assert new_land is not None + assert new_land.tapped is True + + # Check mana pool + player = new_state.players[0] + assert hasattr(player, "mana_pool") + assert player.mana_pool["R"] == 1 # Mountain produces red mana + + def test_execute_tap_different_basic_lands(self): + """Test executing tap different basic lands.""" + basic_lands = [ + ("Plains", "W"), + ("Island", "U"), + ("Swamp", "B"), + ("Mountain", "R"), + ("Forest", "G"), + ] + + for land_name, expected_mana in basic_lands: + game_state = create_empty_game_state() + + land = Card(name=land_name, card_type="Land", instance_id=1) + land.tapped = False + game_state.players[0].battlefield.add_card(land) + + action = Action( + action_type=ActionType.TAP_FOR_MANA, + player_id=0, + card=land, + ) + + executor = TapForManaExecutor() + new_state = executor.execute(action, game_state) + + player = new_state.players[0] + assert player.mana_pool[expected_mana] == 1 + + def test_execute_tap_mana_creature(self): + """Test executing tap mana-producing creature.""" + game_state = create_empty_game_state() + + mana_dork = Card( + name="Llanowar Elves", + card_type="Creature", + oracle_text="{T}: Add {G}.", + instance_id=1, + ) + mana_dork.tapped = False + game_state.players[0].battlefield.add_card(mana_dork) + + action = Action( + action_type=ActionType.TAP_FOR_MANA, + player_id=0, + card=mana_dork, + additional_choices={"mana_ability": "{T}: Add {G}."}, + ) + + executor = TapForManaExecutor() + new_state = executor.execute(action, game_state) + + # Find creature in new state + new_creature = None + for card in new_state.players[0].battlefield.cards: + if card.instance_id == 1: + new_creature = card + break + + assert new_creature is not None + assert new_creature.tapped is True + + # Check mana pool + player = new_state.players[0] + assert player.mana_pool["G"] == 1 + + +class TestPayManaValidator: + """Test PayManaValidator.""" + + def test_valid_mana_payment(self): + """Test valid mana payment from pool.""" + game_state = create_empty_game_state() + game_state.players[0].mana_pool = { + "W": 1, + "U": 1, + "B": 0, + "R": 2, + "G": 1, + "colorless": 3, + } + + action = Action( + action_type=ActionType.PAY_MANA, + player_id=0, + mana_payment={"R": 2, "colorless": 1}, + ) + + validator = PayManaValidator() + assert validator.validate(action, game_state) is True + + def test_valid_pay_with_colorless(self): + """Test paying colored mana with colorless.""" + game_state = create_empty_game_state() + game_state.players[0].mana_pool = { + "W": 0, + "U": 0, + "B": 0, + "R": 1, + "G": 0, + "colorless": 5, + } + + action = Action( + action_type=ActionType.PAY_MANA, + player_id=0, + mana_payment={"R": 2}, # Have 1 R + 5 colorless, can pay 2 R + ) + + validator = PayManaValidator() + assert validator.validate(action, game_state) is True + + def test_invalid_no_mana_payment(self): + """Test mana payment without specifying payment.""" + game_state = create_empty_game_state() + + action = Action( + action_type=ActionType.PAY_MANA, + player_id=0, + mana_payment=None, # No payment specified + ) + + validator = PayManaValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_insufficient_mana(self): + """Test mana payment with insufficient mana.""" + game_state = create_empty_game_state() + game_state.players[0].mana_pool = { + "W": 0, + "U": 0, + "B": 0, + "R": 1, + "G": 0, + "colorless": 0, + } + + action = Action( + action_type=ActionType.PAY_MANA, + player_id=0, + mana_payment={"R": 3}, # Need 3 R but only have 1 + ) + + validator = PayManaValidator() + assert validator.validate(action, game_state) is False + + def test_invalid_negative_payment(self): + """Test mana payment with negative values.""" + game_state = create_empty_game_state() + game_state.players[0].mana_pool = { + "W": 1, + "U": 1, + "B": 1, + "R": 1, + "G": 1, + "colorless": 1, + } + + action = Action( + action_type=ActionType.PAY_MANA, + player_id=0, + mana_payment={"R": -1}, # Negative payment + ) + + validator = PayManaValidator() + assert validator.validate(action, game_state) is False + + +class TestPayManaExecutor: + """Test PayManaExecutor.""" + + def test_execute_mana_payment(self): + """Test executing mana payment.""" + game_state = create_empty_game_state() + game_state.players[0].mana_pool = { + "W": 1, + "U": 1, + "B": 1, + "R": 3, + "G": 1, + "colorless": 2, + } + + action = Action( + action_type=ActionType.PAY_MANA, + player_id=0, + mana_payment={"R": 2, "colorless": 1}, + ) + + executor = PayManaExecutor() + new_state = executor.execute(action, game_state) + + # Check remaining mana pool + player = new_state.players[0] + assert player.mana_pool["R"] == 1 # 3 - 2 = 1 + assert player.mana_pool["colorless"] == 1 # 2 - 1 = 1 + assert player.mana_pool["W"] == 1 # Unchanged + assert player.mana_pool["U"] == 1 # Unchanged + assert player.mana_pool["G"] == 1 # Unchanged + + def test_execute_pay_with_colorless_overflow(self): + """Test paying with colorless mana when specific color insufficient.""" + game_state = create_empty_game_state() + game_state.players[0].mana_pool = { + "W": 0, + "U": 0, + "B": 0, + "R": 1, + "G": 0, + "colorless": 5, + } + + action = Action( + action_type=ActionType.PAY_MANA, + player_id=0, + mana_payment={"R": 3}, # Need 3 R, have 1 R + 5 colorless + ) + + executor = PayManaExecutor() + new_state = executor.execute(action, game_state) + + # Should pay 1 from R and 2 from colorless + player = new_state.players[0] + assert player.mana_pool["R"] == 0 # Used up + assert player.mana_pool["colorless"] == 3 # 5 - 2 = 3 + + def test_execute_empty_payment(self): + """Test executing empty mana payment.""" + game_state = create_empty_game_state() + game_state.players[0].mana_pool = { + "W": 1, + "U": 1, + "B": 1, + "R": 1, + "G": 1, + "colorless": 1, + } + + action = Action( + action_type=ActionType.PAY_MANA, + player_id=0, + mana_payment={}, # Empty payment + ) + + executor = PayManaExecutor() + new_state = executor.execute(action, game_state) + + # Mana pool should remain unchanged + player = new_state.players[0] + for mana_type in ["W", "U", "B", "R", "G", "colorless"]: + assert player.mana_pool[mana_type] == 1