""" MTG Rules Engine - Main Rules Engine Module Core rules engine for an online Magic: The Gathering application. Enforces rule compliance during gameplay by validating: - Card casting (type, mana cost, timing) - Creature combat (attackers, blockers, damage assignment) - Spell resolution (targets, costs, effects) - Zone transitions (enters/leaves battlefield, stack) - Keyword ability interactions - State-based actions (lethal damage, legend rules) Built on the hardcoded keyword database from keywords_db.py and the validator from keyword_validator.py. """ from .keywords_db import ( KEYWORD_ABILITIES, KEYWORD_ACTIONS, KEYWORD_VARIANTS, KEYWORD_TYPES, RULES_INDEX, get_keyword_definition, get_all_keywords, ) from .keyword_validator import KeywordValidator, KeywordValidationError # ============================================================================= # DATA MODELS # ============================================================================= class CardType: """Card types in Magic: The Gathering.""" CREATURE = "creature" SPELL = "spell" LAND = "land" ENCHANTMENT = "enchantment" ARTIFACT = "artifact" PLANEWALKER = "planeswalker" INSTANT = "instant" SORCERY = "sorcery" BATTLE = "battle" CONSPIRACY = "conspiracy" SCHEME = "scheme" PLANE = "plane" PHENOMENON = "phenomenon" VANGUARD = "vanguard" SAGA = "saga" OMEGA = "omega" CASE = "case" DUNGEON = "dungeon" ROOM = "room" DOOR = "door" class CardTypeGroup: """Groups of card types.""" CREATURES = {CardType.CREATURE} PERMANENTS = { CardType.CREATURE, CardType.LAND, CardType.ENCHANTMENT, CardType.ARTIFACT, CardType.PLANEWALKER, } SPELLS = {CardType.INSTANT, CardType.SORCERY} PERMANENT_SPELLS = {CardType.LAND, CardType.ENCHANTMENT, CardType.ARTIFACT, CardType.PLANEWALKER} class Zone: """Game zones.""" HAND = "hand" LIBRARY = "library" EXILE = "exile" GRAVEYARD = "graveyard" COMMAND_ZONE = "command_zone" BATTLEFIELD = "battlefield" STACK = "stack" PLANAR_DECK = "planar_deck" SCHEME_DECK = "scheme_deck" DUNGEON = "dungeon" class Phase: """Turn phases.""" UNTAP = "untap" DRAW = "draw" MAIN_PHASE = "main_phase" PRECOMBAT = "precombat" COMBAT = "combat" BEGINNING_COMBAT = "beginning_combat" DECLARE_ATTACKERS = "declare_attackers" DECLARE_BLOCKERS = "declare_blockers" COMBAT_DAMAGE = "combat_damage" END_COMBAT = "end_combat" END_PHASE = "end_phase" END_STEP = "end_step" CLEANUP = "cleanup" class ZoneTransitionEvent: """Represents an object moving between zones.""" def __init__(self, object_id: str, from_zone: str, to_zone: str, object_type: str = None): self.object_id = object_id self.from_zone = from_zone self.to_zone = to_zone self.object_type = object_type def is_enters_battlefield(self) -> bool: return self.to_zone == Zone.BATTLEFIELD def is_leaves_battlefield(self) -> bool: return self.from_zone == Zone.BATTLEFIELD class GameAction: """Represents a game action that may need validation.""" def __init__(self, action_type: str, details: dict = None): self.action_type = action_type self.details = details or {} def __repr__(self): return f"GameAction({self.action_type}, {self.details})" # ============================================================================= # ZONE MANAGEMENT # ============================================================================= class ZoneManager: """Manages object positions in game zones.""" def __init__(self): self.zones = {zone: [] for zone in Zone.__dict__.values() if not zone.startswith("_")} def add_object(self, object_id: str, zone: str, object_type: str = None): """Add an object to a zone.""" if zone not in self.zones: raise ValueError(f"Invalid zone: {zone}") self.zones[zone].append({ "id": object_id, "type": object_type, "added_at": len(self.zones[zone]), }) def remove_object(self, object_id: str, zone: str) -> dict | None: """Remove an object from a zone. Returns the object data or None.""" if zone not in self.zones: return None for i, obj in enumerate(self.zones[zone]): if obj["id"] == object_id: return self.zones[zone].pop(i) return None def get_objects_in_zone(self, zone: str) -> list[dict]: """Get all objects in a zone.""" return self.zones.get(zone, []) def get_object(self, object_id: str) -> dict | None: """Find an object by ID across all zones.""" for zone_objs in self.zones.values(): for obj in zone_objs: if obj["id"] == object_id: return obj, zone return None def transition(self, object_id: str, from_zone: str, to_zone: str, object_type: str = None) -> ZoneTransitionEvent: """ Transition an object between zones. Returns: ZoneTransitionEvent with the transition details. """ obj = self.remove_object(object_id, from_zone) if obj is None: raise ValueError(f"Object {object_id} not found in zone {from_zone}") self.add_object(object_id, to_zone, object_type) return ZoneTransitionEvent(object_id, from_zone, to_zone, object_type) # ============================================================================= # CARD PARSE AND VALIDATE # ============================================================================= class CardParser: """ Parse and validate card data. Extracts card type, mana cost, keywords, and abilities from card text. Validates the card against MTG rules. """ def __init__(self): self.validator = KeywordValidator() def parse_card(self, card_data: dict) -> dict: """ Parse and validate card data. Args: card_data: Dictionary with card information (name, type, text, mana_cost, etc.) Returns: Validated card dictionary. Raises: KeywordValidationError: If the card has invalid keyword usage. """ validated = { "id": card_data.get("id", "unknown"), "name": card_data.get("name", "Unknown"), "type": card_data.get("type", "unknown").lower(), "mana_cost": card_data.get("mana_cost", ""), "text": card_data.get("text", ""), "keywords": self._extract_keywords(card_data.get("text", "")), "abilities": self._extract_abilities(card_data.get("text", "")), "is_valid": True, "errors": [], } # Validate card type if validated["type"] not in CardTypeGroup.CREATURES and validated["type"] not in CardTypeGroup.SPELLS: validated["is_valid"] = False validated["errors"].append(f"Invalid card type: {validated['type']}") # Validate keywords if validated["text"]: validation_results = self.validator.validate_card_text(validated["text"]) for result in validation_results: if not result["valid"]: validated["is_valid"] = False validated["errors"].append( f"Invalid keyword usage: {result['keyword']}" ) return validated def _extract_keywords(self, text: str) -> list[str]: """Extract keywords from card text.""" keywords = set() for kw in get_all_keywords(): if kw.lower() in text.lower(): keywords.add(kw) return sorted(keywords) def _extract_abilities(self, text: str) -> list[dict]: """Extract abilities from card text.""" abilities = [] # Simple extraction - in production, use NLP/parsing for line in text.split("\n"): line = line.strip() if line: # Check for keyword abilities for kw, data in KEYWORD_ABILITIES.items(): if kw in line.lower(): abilities.append({ "keyword": kw, "text": line, "definition": data["definition"], "rule": data["rule"], "type": data["type"], }) return abilities def validate_casting(self, card: dict, player_state: dict) -> dict: """ Validate if a card can be cast. Args: card: The validated card dictionary. player_state: Dictionary with player's current state (mana, life, etc.) Returns: Validation result with errors and warnings. """ result = { "can_cast": True, "errors": [], "warnings": [], } # Validate card type for casting if card["type"] not in CardTypeGroup.SPELLS and card["type"] not in CardTypeGroup.PERMANENT_SPELLS: result["can_cast"] = False result["errors"].append(f"Card type '{card['type']}' cannot be cast") return result # Validate timing (instant vs sorcery) if card["type"] == CardType.SORCERY: if "main_phase" not in player_state.get("current_phase", ""): result["can_cast"] = False result["errors"].append("Sorceries can only be cast during the main phase") # Validate mana cost mana_cost = card.get("mana_cost", "") if mana_cost: required_mana = self._parse_mana_cost(mana_cost) available_mana = player_state.get("available_mana", {}) if required_mana and not self._can_pay_cost(required_mana, available_mana): result["can_cast"] = False result["errors"].append( f"Insufficient mana. Required: {required_mana}, Available: {available_mana}" ) return result def _parse_mana_cost(self, cost: str) -> dict: """Parse a mana cost string into a structured format.""" # Simple parser - production would use regex costs = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0, "E": 0, "X": 0} import re for match in re.finditer(r"\{([WUBRGCEEXS]|[\d]+)\}", cost): sym = match.group(1) if sym in costs: costs[sym] += 1 elif sym.isdigit(): costs["C"] += int(sym) elif sym == "E": costs["E"] += 1 return {k: v for k, v in costs.items() if v > 0} def _can_pay_cost(self, required: dict, available: dict) -> bool: """Check if a mana cost can be paid.""" total_required = sum(required.values()) total_available = sum(available.values()) return total_available >= total_required # ============================================================================= # COMBAT RESOLVER # ============================================================================= class CombatResolver: """ Resolves combat phase according to MTG rules. Handles: - Attacker declaration - Blocker declaration - Combat damage assignment - Death resolution (deathtouch, lethal damage) """ def __init__(self): self.validator = KeywordValidator() self.zone_manager = ZoneManager() def resolve_turn(self, game_state: dict) -> dict: """ Resolve a full turn. Args: game_state: Dictionary with game state information. Returns: Turn resolution results. """ results = { "phases_resolved": [], "actions_taken": [], "errors": [], } # Untap phase results["phases_resolved"].append("untap") results["actions_taken"].append(self._untap_phase(game_state)) # Draw phase results["phases_resolved"].append("draw") results["actions_taken"].append(self._draw_phase(game_state)) # Main phase results["phases_resolved"].append("main_phase") results["actions_taken"].append(self._main_phase(game_state)) # Pre-combat results["phases_resolved"].append("precombat") # Combat phase if game_state.get("has_combat"): results["phases_resolved"].append("combat") combat_result = self._resolve_combat(game_state) results["actions_taken"].append(combat_result) # End phase results["phases_resolved"].append("end_phase") results["actions_taken"].append(self._end_phase(game_state)) return results def _untap_phase(self, game_state: dict) -> dict: """Resolve the untap phase.""" return {"action": "untap", "untapped": game_state.get("untapped_permanents", [])} def _draw_phase(self, game_state: dict) -> dict: """Resolve the draw phase.""" draw_amount = game_state.get("draw_amount", 1) return {"action": "draw", "drawn": draw_amount} def _main_phase(self, game_state: dict) -> dict: """Resolve the main phase (player actions).""" # Player takes actions here - simplified return {"action": "main_phase", "actions": game_state.get("player_actions", [])} def _resolve_combat(self, game_state: dict) -> dict: """Resolve the combat phase.""" result = { "action": "combat", "attackers": [], "blockers": [], "damage_assigned": [], "deaths": [], "errors": [], } # Get attackers and blockers attackers = game_state.get("attackers", []) blockers = game_state.get("blockers", []) # Validate attackers for attacker in attackers: validation = self._validate_attacker(attacker, game_state) if validation["valid"]: result["attackers"].append(attacker) else: result["errors"].extend(validation["errors"]) # Validate blockers for blocker in blockers: validation = self._validate_blocker(blocker, game_state) if validation["valid"]: result["blockers"].append(blocker) else: result["errors"].extend(validation["errors"]) # Assign combat damage result["damage_assigned"] = self._assign_damage(attackers, blockers, game_state) # Process deaths result["deaths"] = self._process_deaths(game_state) return result def _validate_attacker(self, attacker: dict, game_state: dict) -> dict: """Validate an attacker.""" result = {"valid": True, "errors": []} # Check for haste (summoning sickness) if not attacker.get("haste") and not attacker.get("untapped"): result["valid"] = False result["errors"].append(f"Attacker {attacker.get('id')} has summoning sickness") # Check if attacker is a creature if attacker.get("type") != "creature": result["valid"] = False result["errors"].append(f"Attacker {attacker.get('id')} is not a creature") return result def _validate_blocker(self, blocker: dict, game_state: dict) -> dict: """Validate a blocker.""" result = {"valid": True, "errors": []} # Check if blocker is a creature if blocker.get("type") != "creature": result["valid"] = False result["errors"].append(f"Blocker {blocker.get('id')} is not a creature") return result # Check evasion abilities attackers = game_state.get("attackers", []) blocker_evasion = blocker.get("evasion", []) for attacker in attackers: attacker_evasion = attacker.get("evasion", []) if not self._blocker_can_block(blocker_evasion, attacker_evasion): result["valid"] = False result["errors"].append( f"Blocker {blocker.get('id')} can't block attacker {attacker.get('id')} " f"due to evasion abilities" ) break return result def _blocker_can_block(self, blocker_evasion: list[str], attacker_evasion: list[str]) -> bool: """Check if a blocker can block an attacker based on evasion abilities.""" if not attacker_evasion: return True for ev in attacker_evasion: if ev == "flying": if "flying" not in blocker_evasion and "reach" not in blocker_evasion: return False elif ev == "shadow": if "shadow" not in blocker_evasion: return False elif ev == "horsemanship": if "horsemanship" not in blocker_evasion: return False elif ev == "intimidate": if "intimidate" not in blocker_evasion: return False elif ev == "flanking": if "flanking" not in blocker_evasion: return False elif ev == "skulk": # Skulk: can't be blocked by creatures with greater power # We don't have power info here, so we assume it can block pass elif ev == "menace": if len(blocker_evasion) < 2: return False return True def _assign_damage(self, attackers: list[dict], blockers: list[dict], game_state: dict) -> list[dict]: """Assign combat damage.""" damage_assigned = [] for attacker in attackers: attacker_power = attacker.get("power", 0) attacker_id = attacker.get("id") # Check for trample has_trample = "trample" in attacker.get("keywords", []) trample_over_pws = "trample_over_planeswalkers" in attacker.get("keywords", []) # Assign damage to blockers first for blocker in blockers: blocker_id = blocker.get("id") blocker_toughness = blocker.get("toughness", 0) damage_to_blocker = min(attacker_power, blocker_toughness) damage_assigned.append({ "attacker_id": attacker_id, "target_id": blocker_id, "damage": damage_to_blocker, "type": "combat", }) attacker_power -= damage_to_blocker if attacker_power <= 0: break # Assign remaining damage to defender (trample) if attacker_power > 0 and (has_trample or trample_over_pws): defender_life = game_state.get("defender_life", 20) damage_to_defender = min(attacker_power, defender_life) damage_assigned.append({ "attacker_id": attacker_id, "target_id": "defender", "damage": damage_to_defender, "type": "combat", }) return damage_assigned def _process_deaths(self, game_state: dict) -> list[str]: """Process state-based death resolution.""" deaths = [] # Check for lethal damage for damage in game_state.get("damage_assigned", []): target = damage.get("target_id") amount = damage.get("damage", 0) if target == "defender": # Player takes damage game_state["defender_life"] -= amount if game_state["defender_life"] <= 0: deaths.append("defender") else: # Creature takes damage target_info = game_state.get("targets", {}).get(target) if target_info: toughness = target_info.get("toughness", 0) damage_marked = target_info.get("damage_marked", 0) + amount # Check for deathtouch attacker_id = damage.get("attacker_id") attacker_has_deathtouch = any( k in game_state.get("attackers", {}).get(attacker_id, {}).get("keywords", []) for k in ["deathtouch"] ) if attacker_has_deathtouch: # Deathtouch: any nonzero damage is lethal if amount > 0: deaths.append(target) else: # Normal damage: check for lethal if damage_marked >= toughness: deaths.append(target) return deaths def _end_phase(self, game_state: dict) -> dict: """Resolve the end phase.""" return {"action": "end_phase", "life": game_state.get("defender_life", 20)} # ============================================================================= # MAIN RULES ENGINE # ============================================================================= class RulesEngine: """ Main rules engine for Magic: The Gathering. Provides high-level rule enforcement by composing: - CardParser for card validation - ZoneManager for zone tracking - CombatResolver for combat resolution - KeywordValidator for keyword validation Usage: engine = RulesEngine() result = engine.validate_action(card, player_state, action) """ def __init__(self): self.validator = KeywordValidator() self.card_parser = CardParser() self.zone_manager = ZoneManager() self.combat_resolver = CombatResolver() def validate_action(self, card: dict, player_state: dict, action: GameAction) -> dict: """ Validate a game action. Args: card: The validated card dictionary. player_state: Dictionary with player's current state. action: The GameAction to validate. Returns: Validation result dictionary. """ result = { "valid": True, "errors": [], "warnings": [], "action": action.action_type, } # Validate based on action type if action.action_type == "cast": result = self._validate_cast(card, player_state, result) elif action.action_type == "attack": result = self._validate_attack(card, player_state, result) elif action.action_type == "block": result = self._validate_block(card, player_state, result) elif action.action_type == "resolve_combat": result = self._validate_combat_resolution(player_state, result) elif action.action_type == "zone_transition": result = self._validate_zone_transition(card, player_state, result) else: result["valid"] = False result["errors"].append(f"Unknown action type: {action.action_type}") return result def _validate_cast(self, card: dict, player_state: dict, result: dict) -> dict: """Validate a spell casting action.""" cast_result = self.card_parser.validate_casting(card, player_state) if not cast_result["can_cast"]: result["valid"] = False result["errors"].extend(cast_result["errors"]) result["warnings"].extend(cast_result.get("warnings", [])) # Validate targets if card.get("targets"): for target in card["targets"]: if not self.validator.is_valid_keyword(target): result["valid"] = False result["errors"].append(f"Invalid target keyword: {target}") return result def _validate_attack(self, card: dict, player_state: dict, result: dict) -> dict: """Validate an attack action.""" # Check if card is a creature if card["type"] != "creature": result["valid"] = False result["errors"].append("Only creatures can attack") return result # Check summoning sickness if not card.get("haste") and not card.get("untapped"): result["valid"] = False result["errors"].append("Creature has summoning sickness") return result def _validate_block(self, card: dict, player_state: dict, result: dict) -> dict: """Validate a block action.""" if card["type"] != "creature": result["valid"] = False result["errors"].append("Only creatures can block") return result # Check evasion abilities attacker = player_state.get("attacker", {}) attacker_evasion = attacker.get("evasion", []) blocker_evasion = card.get("evasion", []) if not self.combat_resolver._blocker_can_block(blocker_evasion, attacker_evasion): result["valid"] = False result["errors"].append("Blocker can't block attacker due to evasion") return result def _validate_combat_resolution(self, player_state: dict, result: dict) -> dict: """Validate combat resolution.""" combat_result = self.combat_resolver.resolve_turn(player_state) if combat_result.get("errors"): result["valid"] = False result["errors"].extend(combat_result["errors"]) return result def _validate_zone_transition(self, card: dict, player_state: dict, result: dict) -> dict: """Validate a zone transition action.""" from_zone = player_state.get("from_zone") to_zone = player_state.get("to_zone") if not from_zone or not to_zone: result["valid"] = False result["errors"].append("Zone transition requires from_zone and to_zone") return result # Validate transition valid_transitions = { (Zone.HAND, Zone.BATTLEFIELD): CardTypeGroup.PERMANENT_SPELLS, (Zone.LIBRARY, Zone.HAND): set(), (Zone.HAND, Zone.LIBRARY): set(), (Zone.BATTLEFIELD, Zone.GRAVEYARD): set(), (Zone.GRAVEYARD, Zone.HAND): set(), (Zone.BATTLEFIELD, Zone.EXILE): set(), (Zone.EXILE, Zone.BATTLEFIELD): set(), } transition_key = (from_zone, to_zone) if transition_key not in valid_transitions: result["valid"] = False result["errors"].append(f"Invalid zone transition: {from_zone} -> {to_zone}") return result return result def get_rules_index(self) -> dict: """Get the rules index for reference.""" return RULES_INDEX # ============================================================================= # GAME STATE MANAGER # ============================================================================= class GameState: """ Manages the state of a Magic: The Gathering game. Tracks: - Players and their resources - Card zones (hand, battlefield, graveyard, etc.) - Stack of pending effects - Current phase and turn - Combat state """ def __init__(self, player_count: int = 2): self.player_count = player_count self.current_player = 0 self.current_phase = Phase.UNTAP self.stack = [] self.zone_manager = ZoneManager() self.combat_state = { "attackers": [], "blockers": [], "damage_assigned": [], "phase": None, } self.game_state = { "players": self._init_players(), "has_combat": False, "defender_life": 20, "targets": {}, } def _init_players(self) -> list[dict]: """Initialize player states.""" players = [] for i in range(self.player_count): players.append({ "id": i, "life": 20, "mana": 0, "mana_available": 0, "hand": [], "zone": Zone.HAND, }) return players def advance_phase(self) -> str: """Advance to the next phase.""" phase_order = [ Phase.UNTAP, Phase.DRAW, Phase.MAIN_PHASE, Phase.PRECOMBAT, Phase.BEGINNING_COMBAT, Phase.DECLARE_ATTACKERS, Phase.DECLARE_BLOCKERS, Phase.COMBAT_DAMAGE, Phase.END_COMBAT, Phase.END_PHASE, Phase.END_STEP, Phase.CLEANUP, ] current_idx = phase_order.index(self.current_phase) next_idx = (current_idx + 1) % len(phase_order) self.current_phase = phase_order[next_idx] return self.current_phase def get_player_state(self, player_id: int) -> dict: """Get a player's state.""" return self.game_state["players"][player_id] def set_combat_state(self, state: dict): """Set the combat phase state.""" self.combat_state.update(state) self.game_state["has_combat"] = True def get_engine(self) -> RulesEngine: """Get the rules engine for this game state.""" return RulesEngine()