720 lines
28 KiB
Python
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
|