Add MTG rules engine source files
This commit is contained in:
@@ -0,0 +1,719 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user