""" Magic: The Gathering Rules Engine This module provides the core rules engine for validating and simulating Magic: The Gathering gameplay. It uses the hardcoded keyword database to ensure rule compliance during gameplay. Usage: from mtg_rules_engine.engine import RulesEngine engine = RulesEngine() # Validate a card's abilities card = { "name": "Garruk the Wreathshaper", "text": "Flying, trample. When Garruk enters, create a 1/1 green Elf creature token.", "type": "CREATURE", "power_toughness": (6, 6), "color": ["GREEN"], } errors = engine.validate_card(card) print(f"Validation errors: {errors}") # Validate a game action action = { "type": "attack", "attacker": "Garruk the Wreathshaper", "attacker_power": 6, "target": "Opponent's creature", "blocking_creatures": [{"name": "Garruk the Wreathshaper", "power": 6, "toughness": 6, "abilities": ["flying", "trample"]}], } errors = engine.validate_action(action) print(f"Action validation errors: {errors}") """ from typing import Any, Dict, List, Optional, Set, Tuple, Union from .keywords import ( ABILITY_WORDS, KEYWORD_ACTIONS, KEYWORD_ABILITIES, get_all_keywords, get_keyword_info, is_valid_keyword, get_keyword_type, ) from .validator import KeywordValidator class RulesError(Exception): """Base exception for rules validation errors.""" pass class RulesError: """Represents a rules validation error.""" def __init__(self, message: str, rule: Optional[str] = None, keyword: Optional[str] = None, severity: str = "error"): self.message = message self.rule = rule self.keyword = keyword self.severity = severity # "error", "warning", "info" def __str__(self): return f"{self.message}" class RulesEngine: """ Core rules engine for Magic: The Gathering. This engine validates cards, game actions, and game states using the hardcoded keyword database. """ def __init__(self): """Initialize the rules engine with the keyword validator.""" self.validator = KeywordValidator() self._all_keywords: Set[str] = get_all_keywords() # ========================================================================= # CARD VALIDATION # ========================================================================= def validate_card(self, card: Dict[str, Any]) -> List[Dict[str, str]]: """ Validate a card's properties against the rules. Args: card: Dictionary containing card properties: - name (str): Card name - text (str): Rules text - type (str): Card type (CREATURE, INSTANT, SORCERY, ENCHANTMENT, LAND) - color (List[str]): Card colors - power_toughness (Tuple[int, int] or None): Power/toughness for creatures - mana_cost (str or None): Mana cost - abilities (List[str] or None): List of keywords - supertypes (List[str] or None): Supertypes (LEGENDARY, etc.) - subtypes (List[str] or None): Subtypes - other (Dict[str, Any]): Other properties Returns: List of validation errors (empty if no errors) """ errors: List[Dict[str, str]] = [] # Validate card name name = card.get("name", "") if not name: errors.append({"message": "Card must have a name", "rule": "201"}) # Validate card type card_type = card.get("type", "") valid_types = {"CREATURE", "INSTANT", "SORCERY", "ENCHANTMENT", "LAND", "ARTIFACT", "PLANEWALKER", "BATTLE", "DUNGEON", "CONSPIRACY", "SCHEME", "PHENOMENON", "ADVENTURER", "OMEN", "BATTLE", "CARDFACE"} if card_type and card_type not in valid_types: errors.append({ "message": f"Invalid card type: {card_type}", "rule": "205", "valid_types": list(valid_types) }) # Validate creature requirements if card_type == "CREATURE": self._validate_creature(card, errors) # Validate spell requirements if card_type in ("INSTANT", "SORCERY"): self._validate_spell(card, errors) # Validate enchantment requirements if card_type == "ENCHANTMENT": self._validate_enchantment(card, errors) # Validate land requirements if card_type == "LAND": self._validate_land(card, errors) # Validate artifact requirements if card_type == "ARTIFACT": self._validate_artifact(card, errors) # Validate planeswalker requirements if card_type == "PLANEWALKER": self._validate_planeswalker(card, errors) # Validate keyword usage in text text = card.get("text", "") keyword_errors = self.validator.validate_card_text(text) errors.extend(keyword_errors) return errors def _validate_creature(self, card: Dict[str, Any], errors: List[Dict[str, str]]): """Validate creature-specific rules.""" power_toughness = card.get("power_toughness") if power_toughness: power, toughness = power_toughness if power < -9: errors.append({"message": "Power cannot be less than -9", "rule": "208"}) if toughness < 0: errors.append({"message": "Toughness cannot be negative", "rule": "208"}) if power < 0 and toughness < 0: errors.append({"message": "A creature can't have both negative power and toughness", "rule": "208"}) # Validate basic creature rules abilities = card.get("abilities", []) if abilities: for ability in abilities: if is_valid_keyword(ability): # Check if ability is appropriate for the card type ability_info = get_keyword_info(ability) if ability_info: keyword_type = ability_info.get("category", "unknown") if keyword_type == "keyword_action": # Some actions may not be appropriate as abilities pass # We'll handle this in more detail elif keyword_type == "characteristic_defining": # Characteristic-defining abilities need special handling pass def _validate_spell(self, card: Dict[str, Any], errors: List[Dict[str, str]]): """Validate spell-specific rules.""" mana_cost = card.get("mana_cost") if mana_cost is None: errors.append({"message": "Spell must have a mana cost or alternative cost", "rule": "202"}) # Validate spell text for keywords text = card.get("text", "") keywords = self.validator.find_keywords_in_text(text) if keywords: for keyword in keywords: info = get_keyword_info(keyword) if info: keyword_type = info.get("category", "unknown") if keyword_type == "keyword_action": # Check if the action is appropriate for a spell pass elif keyword_type == "keyword_ability": # Spells can have spell abilities pass def _validate_enchantment(self, card: Dict[str, Any], errors: List[Dict[str, str]]): """Validate enchantment-specific rules.""" text = card.get("text", "") keywords = self.validator.find_keywords_in_text(text) for keyword in keywords: info = get_keyword_info(keyword) if info: keyword_type = info.get("category", "unknown") if keyword_type == "keyword_ability": ability_type = info.get("type", "unknown") if ability_type in ("static", "triggered", "activated"): pass # Enchantments can have these ability types def _validate_land(self, card: Dict[str, Any], errors: List[Dict[str, str]]): """Validate land-specific rules.""" subtypes = card.get("subtypes", []) if "basic" in subtypes: # Basic lands can only have certain basic land types valid_basic_types = {"PLAINS", "ISLAND", "SWAMP", "MOUNTAIN", "FOREST"} if not subtypes or not any(st in valid_basic_types for st in subtypes): errors.append({ "message": "Basic land must have a valid basic land type", "rule": "305", "valid_types": list(valid_basic_types) }) def _validate_artifact(self, card: Dict[str, Any], errors: List[Dict[str, str]]): """Validate artifact-specific rules.""" pass # Artifacts can have almost any ability def _validate_planeswalker(self, card: Dict[str, Any], errors: List[Dict[str, str]]): """Validate planeswalker-specific rules.""" loyalty = card.get("loyalty") if loyalty is not None: if loyalty < 0 or loyalty > 27: errors.append({"message": "Loyalty must be between 0 and 27", "rule": "306"}) # ========================================================================= # GAME ACTION VALIDATION # ========================================================================= def validate_action(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """ Validate a game action for rule compliance. Args: action: Dictionary describing the game action: - type (str): Action type (attack, block, cast, activate, etc.) - details (Dict[str, Any]): Action-specific details Returns: List of validation errors (empty if no errors) """ errors: List[Dict[str, str]] = [] action_type = action.get("type", "").lower() if action_type == "attack": errors.extend(self._validate_attack(action)) elif action_type == "block": errors.extend(self._validate_block(action)) elif action_type == "cast": errors.extend(self._validate_cast(action)) elif action_type == "activate": errors.extend(self._validate_activate(action)) elif action_type == "sacrifice": errors.extend(self._validate_sacrifice(action)) elif action_type == "destroy": errors.extend(self._validate_destroy(action)) elif action_type == "exile": errors.extend(self._validate_exile(action)) elif action_type == "discard": errors.extend(self._validate_discard(action)) elif action_type == "transform": errors.extend(self._validate_transform(action)) elif action_type == "convert": errors.extend(self._validate_convert(action)) elif action_type == "tap": errors.extend(self._validate_tap(action)) elif action_type == "untap": errors.extend(self._validate_untap(action)) else: errors.append({ "message": f"Unknown action type: {action_type}", "rule": "100", }) return errors def _validate_attack(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """Validate an attack action.""" errors = [] attacker = action.get("attacker", {}) attacker_name = attacker.get("name", "") attacker_toughness = attacker.get("toughness", 0) attacker_power = attacker.get("power", 0) attacker_abilities = attacker.get("abilities", []) # Check if attacker is a creature if attacker.get("type") != "CREATURE": errors.append({"message": f"{attacker_name} is not a creature", "rule": "508"}) # Check for defender ability if "defender" in attacker_abilities: errors.append({"message": f"{attacker_name} has defender and can't attack", "rule": "702.3"}) # Check for landwalk landwalk = [a for a in attacker_abilities if a.startswith("landwalk")] if landwalk: defending_player = action.get("defending_player", {}) controlling_land = defending_player.get("controlling_land", []) landwalk_type = landwalk[0] # e.g., "islandwalk" land_type = landwalk_type.split("walk")[0] if "walk" in landwalk_type else landwalk_type if landwalk_type == "nonbasic landwalk": # Need nonbasic land has_nonbasic = any(l.get("is_basic") == False for l in controlling_land) if not has_nonbasic: errors.append({"message": f"Need a nonbasic land to attack", "rule": "702.14"}) elif landwalk_type == "snowwalk": # Need snow land has_snow = any(l.get("is_snow") for l in controlling_land) if not has_snow: errors.append({"message": f"Need a snow land to attack", "rule": "702.14"}) elif landwalk_type == "islandwalk": has_island = any(l.get("land_type") == "ISLAND" for l in controlling_land) if not has_island: errors.append({"message": f"Need an Island to attack", "rule": "702.14"}) elif landwalk_type == "mountainwalk": has_mountain = any(l.get("land_type") == "MOUNTAIN" for l in controlling_land) if not has_mountain: errors.append({"message": f"Need a Mountain to attack", "rule": "702.14"}) elif landwalk_type == "plainswalk": has_plains = any(l.get("land_type") == "PLAINS" for l in controlling_land) if not has_plains: errors.append({"message": f"Need a Plains to attack", "rule": "702.14"}) elif landwalk_type == "swampwalk": has_swamp = any(l.get("land_type") == "SWAMP" for l in controlling_land) if not has_swamp: errors.append({"message": f"Need a Swamp to attack", "rule": "702.14"}) elif landwalk_type == "forestwalk": has_forest = any(l.get("land_type") == "FOREST" for l in controlling_land) if not has_forest: errors.append({"message": f"Need a Forest to attack", "rule": "702.14"}) # Check for flying if "flying" in attacker_abilities: blockers = action.get("blocking_creatures", []) can_block = any( "flying" in b.get("abilities", []) or "reach" in b.get("abilities", []) for b in blockers ) if blockers and not can_block: errors.append({ "message": f"Cannot attack because {attacker_name} has flying and can't be blocked", "rule": "702.9" }) # Check for trample if "trample" in attacker_abilities: # Trample can attack even if blocked pass return errors def _validate_block(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """Validate a block action.""" errors = [] blocker = action.get("blocker", {}) attacker = action.get("attacker", {}) blocker_abilities = blocker.get("abilities", []) attacker_abilities = attacker.get("abilities", []) # Check if blocker is a creature if blocker.get("type") != "CREATURE": errors.append({"message": f"{blocker.get('name', 'Creature')} is not a creature", "rule": "509"}) # Check for defender if "defender" in blocker_abilities: errors.append({"message": f"{blocker.get('name', 'Creature')} has defender and can't block", "rule": "702.3"}) # Check for flying if "flying" in attacker_abilities: can_block = any( "flying" in b.get("abilities", []) or "reach" in b.get("abilities", []) for b in [blocker] ) if not can_block: errors.append({ "message": f"{attacker.get('name', 'Creature')} has flying and can't be blocked", "rule": "702.9" }) # Check for shadow if "shadow" in attacker_abilities: can_block = any( "shadow" in b.get("abilities", []) for b in [blocker] ) if not can_block: errors.append({ "message": f"{attacker.get('name', 'Creature')} has shadow and can't be blocked", "rule": "702.28" }) # Check for menace if "menace" in attacker_abilities: can_block = len(action.get("blocking_creatures", [blocker])) >= 2 if not can_block: errors.append({ "message": f"{attacker.get('name', 'Creature')} has menace and needs 2 blockers", "rule": "702.111" }) # Check for fear if "fear" in attacker_abilities: can_block = any( b.get("color", []) and any(c in b.get("color", []) for c in ["BLACK"]) or "artifact" in b.get("type", []) for b in [blocker] ) if not can_block: errors.append({ "message": f"{attacker.get('name', 'Creature')} has fear and can't be blocked", "rule": "702.36" }) return errors def _validate_cast(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """Validate a spell cast action.""" errors = [] spell = action.get("spell", {}) # Check if spell is a valid card type spell_type = spell.get("type", "") valid_spell_types = {"INSTANT", "SORCERY", "ENCHANTMENT", "CREATURE", "LAND", "ARTIFACT", "PLANEWALKER"} if spell_type not in valid_spell_types: errors.append({"message": f"Invalid spell type: {spell_type}", "rule": "205"}) # Check timing restrictions spell_type = spell.get("type", "") current_phase = action.get("current_phase", "") if spell_type == "SORCERY": # Sorceries can only be cast during the main phase valid_phases = {"PRECOMBAT_MAIN", "POSTCOMBAT_MAIN"} if current_phase not in valid_phases: errors.append({ "message": f"Sorceries can only be cast during the main phase, not {current_phase}", "rule": "601.1" }) if spell_type == "INSTANT": # Instants can be cast anytime pass # Check for flash if "flash" in spell.get("abilities", []): # Flash spells can be cast any time you could cast an instant pass # Check for haste (if creature spell) if spell_type == "CREATURE" and "haste" in spell.get("abilities", []): # Haste creatures can attack immediately pass return errors def _validate_activate(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """Validate an ability activation action.""" errors = [] ability = action.get("ability", {}) # Check for exhaustion if "exhaust" in ability.get("variants", []) or "exhaust" in ability.get("type", ""): pass # Exhaust is checked during activation # Check for tap cost has_tap_cost = "{T}" in ability.get("cost", "") or "T" in ability.get("cost", "") if has_tap_cost: is_tapped = action.get("source_tapped", False) if is_tapped: errors.append({ "message": "Source is tapped and can't pay {T} cost", "rule": "118.2" }) # Check for sorcery restriction if "sorcery" in ability.get("variant", "").lower(): current_phase = action.get("current_phase", "") valid_phases = {"PRECOMBAT_MAIN", "POSTCOMBAT_MAIN"} if current_phase not in valid_phases: errors.append({ "message": f"Sorcery ability can only be activated during the main phase", "rule": "602" }) return errors def _validate_sacrifice(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """Validate a sacrifice action.""" errors = [] target = action.get("target", {}) # Check if target is a permanent if target.get("type") not in {"CREATURE", "ENCHANTMENT", "ARTIFACT", "LAND", "PLANEWALKER", "BATTLE"}: errors.append({"message": "Can only sacrifice permanents", "rule": "701.21"}) # Check for indestructible if "indestructible" in target.get("abilities", []): errors.append({ "message": f"{target.get('name', 'Permanent')} has indestructible and can't be sacrificed", "rule": "702.12" }) return errors def _validate_destroy(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """Validate a destroy action.""" errors = [] target = action.get("target", {}) # Check for indestructible if "indestructible" in target.get("abilities", []): errors.append({ "message": f"{target.get('name', 'Permanent')} has indestructible and can't be destroyed", "rule": "702.12" }) return errors def _validate_exile(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """Validate an exile action.""" errors = [] target = action.get("target", {}) # Exile can be used on any object pass # Exile is generally allowed return errors def _validate_discard(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """Validate a discard action.""" errors = [] target = action.get("target", {}) # Check if target is in hand if target.get("zone") != "HAND": errors.append({"message": "Can only discard cards from hand", "rule": "701.9"}) return errors def _validate_transform(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """Validate a transform action.""" errors = [] target = action.get("target", {}) # Check if target is a double-faced card if not target.get("is_double_faced"): errors.append({"message": "Can only transform double-faced cards", "rule": "701.27"}) return errors def _validate_convert(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """Validate a convert action.""" errors = [] target = action.get("target", {}) # Check if target is a double-faced card if not target.get("is_double_faced"): errors.append({"message": "Can only convert double-faced cards", "rule": "701.28"}) return errors def _validate_tap(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """Validate a tap action.""" errors = [] target = action.get("target", {}) # Check if target is tapped if target.get("tapped", False): errors.append({"message": f"{target.get('name', 'Permanent')} is already tapped", "rule": "701.26"}) return errors def _validate_untap(self, action: Dict[str, Any]) -> List[Dict[str, str]]: """Validate an untap action.""" errors = [] target = action.get("target", {}) # Check if target is not tapped if not target.get("tapped", False): errors.append({"message": f"{target.get('name', 'Permanent')} is already untapped", "rule": "701.26"}) return errors # ========================================================================= # GAME STATE VALIDATION # ========================================================================= def validate_game_state(self, state: Dict[str, Any]) -> List[Dict[str, str]]: """ Validate a complete game state. Args: state: Dictionary describing the current game state Returns: List of validation errors (empty if no errors) """ errors: List[Dict[str, str]] = [] # Validate players players = state.get("players", []) if len(players) < 2: errors.append({"message": "Game must have at least 2 players", "rule": "100.1"}) # Validate each player's resources for player in players: hand = player.get("hand", []) if len(hand) > 10: errors.append({ "message": f"Player {player.get('name', 'Player')} has too many cards in hand", "rule": "119.3" }) # Validate creatures on battlefield creatures = player.get("creatures", []) for creature in creatures: # Check for legendary uniqueness name = creature.get("name", "") legendary = creature.get("supertypes", []) if "LEGENDARY" in legendary: other_legendaries = [c for c in creatures if c.get("name") == name and c.get("id") != creature.get("id")] if other_legendaries: errors.append({ "message": f"Player {player.get('name', 'Player')} controls multiple legendary {name}", "rule": "704.5j" }) # Validate zones zones = state.get("zones", {}) if "stack" in zones: if not isinstance(zones["stack"], list): errors.append({"message": "Stack must be a list", "rule": "405"}) return errors # ========================================================================= # UTILITY METHODS # ========================================================================= def get_rule_text(self, rule: str) -> str: """ Get the full text of a specific rule. Args: rule: Rule number (e.g., "702.9") Returns: Rule text or empty string if not found """ # In a full implementation, this would fetch from a rules database return f"Rule {rule}: See keywords.py for details" def get_keyword_info_summary(self, keyword: str) -> Dict[str, Any]: """ Get a summary of a keyword's information. Args: keyword: The keyword to look up Returns: Summary dictionary """ info = get_keyword_info(keyword) if info is None: return {"keyword": keyword, "status": "not_found"} return { "keyword": keyword, "category": info.get("category", "unknown"), "rule": info.get("rule", "N/A"), "definition": info.get("definition", "N/A"), "variants": info.get("variants", []), } def search_keywords(self, query: str) -> List[Dict[str, Any]]: """ Search for keywords matching a query. Args: query: Search query Returns: List of matching keywords with info """ results = [] query_lower = query.lower() for keyword in self._all_keywords: if query_lower in keyword.lower(): info = get_keyword_info(keyword) if info: results.append({ "keyword": keyword, "category": info.get("category", "unknown"), "rule": info.get("rule", "N/A"), "definition": info.get("definition", "")[:200], # Truncated }) return results