Files

720 lines
28 KiB
Python

"""
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