Add MTG rules engine source files

This commit is contained in:
2026-07-25 21:12:28 +00:00
parent e8634616d3
commit 165a6f118f
12 changed files with 5353 additions and 0 deletions
+1
View File
@@ -69,3 +69,4 @@ htmlcov/
# State # State
state.json state.json
backend/mtg_rules_engine.zip
+61
View File
@@ -0,0 +1,61 @@
# Magic: The Gathering Rules Engine
A Python-based rules engine designed to validate card text, game actions, and game states against the comprehensive rules of Magic: The Gathering (MTG).
## Overview
This engine provides a programmatic way to handle the complexities of MTG keywords and rules. It allows developers to verify if card text is consistent with established keywords and to validate whether specific game actions are legal according to the rules database.
## Key Features
- **Comprehensive Keyword Database**: Manages hundreds of keyword abilities, keyword actions, and ability words, complete with their corresponding rule numbers and definitions.
- **Card Text Validation**: Analyzes card text to identify keywords and flag potential errors or misspellings.
- **Action & State Validation**: Provides logic to verify if a specific game action (e.g., an attack or a cast) is valid given the current game state.
- **Rule Reference Lookup**: Quick retrieval of rule text and summaries for any supported keyword.
- **Automatic Update System**: Includes an updater to keep the rules database current.
## Project Structure
| File | Description |
| :--- | :--- |
| `engine.py` | High-level interface for the rules engine. |
| `rules_engine.py` | Core logic for rule application and game state validation. |
| `keywords.py` | The primary definitions and data for MTG keywords. |
| `keywords_db.py` | Handles the loading and management of the keyword database. |
| `keyword_validator.py` | Logic for parsing text and validating keywords. |
| `validator.py` | General purpose validation utilities. |
| `updater.py` & `update_check.py` | Tools for checking and applying engine updates. |
| `test_engine.py` | Comprehensive test suite for verifying engine stability. |
## Getting Started
### Prerequisites
- Python 3.10+
### Running Tests
To verify the installation and ensure the engine is functioning correctly, run the test suite:
```bash
python3 test_engine.py
```
## Usage Example
```python
from mtg_rules_engine.engine import RulesEngine
engine = RulesEngine()
# Validate card text
errors = engine.validate_card("Flying, Trample")
if not errors:
print("Card text is valid.")
# Get rule information
info = engine.get_keyword_info('flying')
print(f"Rule {info['rule']}: {info['definition']}")
```
## Maintenance
The engine includes an automated update mechanism. Use `update_check.py` to determine if a newer version of the rules database is available, and `updater.py` to apply those changes.
+39
View File
@@ -0,0 +1,39 @@
"""
Magic: The Gathering Rules Engine
A comprehensive rules engine for validating and simulating Magic: The Gathering gameplay.
Uses a hardcoded keyword database to ensure rule compliance during gameplay.
Modules:
keywords: Hardcoded keyword database with all Magic keywords and their definitions
validator: Keyword validation and analysis utilities
engine: Core rules engine for card and action validation
"""
from .keywords import (
ABILITY_WORDS,
KEYWORD_ACTIONS,
KEYWORD_ABILITIES,
KEYWORD_VARIANTS,
get_all_keywords,
get_keyword_info,
is_valid_keyword,
get_keyword_type,
)
from .validator import KeywordValidator
from .engine import RulesEngine
__all__ = [
# Keyword data
"ABILITY_WORDS",
"KEYWORD_ACTIONS",
"KEYWORD_ABILITIES",
"KEYWORD_VARIANTS",
"get_all_keywords",
"get_keyword_info",
"is_valid_keyword",
"get_keyword_type",
# Classes
"KeywordValidator",
"RulesEngine",
]
+719
View File
@@ -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
@@ -0,0 +1,225 @@
"""
MTG Rules Engine - Keyword Validator Module
Validates keyword usage in card text, spell resolution, and game actions.
Uses the hardcoded keyword database from keywords_db.py.
"""
from .keywords_db import (
KEYWORD_ACTIONS,
KEYWORD_ABILITIES,
ABILITY_WORDS,
KEYWORD_VARIANTS,
KEYWORD_TYPES,
get_keyword_definition,
get_all_keywords,
get_keyword_by_rule,
search_keywords,
)
class KeywordValidationError(Exception):
"""Raised when a keyword usage violates Magic: The Gathering rules."""
def __init__(self, message: str, keyword: str = None, rule: str = None):
super().__init__(message)
self.keyword = keyword
self.rule = rule
class KeywordValidator:
"""
Validates keyword usage in card text and game actions.
This validator ensures:
1. Keywords are valid MTG keywords
2. Keywords are used in appropriate contexts (actions vs abilities)
3. Keyword variants are properly recognized
4. Ability words are distinguished from keyword abilities
"""
def __init__(self):
self._all_keywords = get_all_keywords()
def validate_keyword(self, keyword: str) -> dict:
"""
Validate a keyword and return its metadata.
Args:
keyword: The keyword to validate.
Returns:
Dictionary with keyword metadata.
Raises:
KeywordValidationError: If the keyword is not recognized.
"""
definition = get_keyword_definition(keyword)
if definition is None:
raise KeywordValidationError(
f"Unknown keyword: '{keyword}'. "
f"Valid keywords: {len(self._all_keywords)} total.",
keyword=keyword,
)
return definition
def validate_keyword_in_context(self, keyword: str, context: str) -> str:
"""
Validate a keyword in a specific context (action or ability).
Args:
keyword: The keyword to validate.
context: The context ('action' or 'ability').
Returns:
A validation message.
Raises:
KeywordValidationError: If the keyword is invalid in context.
"""
definition = get_keyword_definition(keyword)
if definition is None:
raise KeywordValidationError(
f"Unknown keyword: '{keyword}'.",
keyword=keyword,
)
if context == "action" and definition["type"] == "keyword_ability":
raise KeywordValidationError(
f"Keyword '{keyword}' is a keyword ability, not a keyword action. "
f"Rule reference: {definition['rule']}",
keyword=keyword,
rule=definition["rule"],
)
if context == "ability" and definition["type"] == "keyword_action":
raise KeywordValidationError(
f"Keyword '{keyword}' is a keyword action, not a keyword ability. "
f"Rule reference: {definition['rule']}",
keyword=keyword,
rule=definition["rule"],
)
return f"Valid keyword: '{keyword}' ({definition['type']})"
def validate_card_text(self, text: str) -> list[dict]:
"""
Validate keyword usage in card text.
Args:
text: The card text to validate.
Returns:
List of validation results for each keyword found.
"""
keywords_found = []
results = []
# Extract keywords from text (simple word-based matching)
for kw in self._all_keywords:
# Check for exact matches (case-insensitive)
if kw.lower() in text.lower():
definition = get_keyword_definition(kw)
if definition:
keywords_found.append(kw)
results.append({
"keyword": kw,
"type": definition["type"],
"rule": definition["rule"],
"valid": True,
})
return results
def validate_action(self, action: str) -> bool:
"""
Check if a string is a valid keyword action.
Args:
action: The action to validate.
Returns:
True if valid, False otherwise.
"""
return action.lower() in [kw.lower() for kw in KEYWORD_ACTIONS]
def validate_ability(self, ability: str) -> bool:
"""
Check if a string is a valid keyword ability.
Args:
ability: The ability to validate.
Returns:
True if valid, False otherwise.
"""
return ability.lower() in [kw.lower() for kw in KEYWORD_ABILITIES]
def is_evasion_ability(self, ability: str) -> bool:
"""
Check if an ability is an evasion ability.
Args:
ability: The ability to check.
Returns:
True if the ability is an evasion ability.
"""
definition = get_keyword_definition(ability)
if definition and definition["ability_type"] == "evasion":
return True
return False
def get_variant_base(self, variant: str) -> str | None:
"""
Get the base keyword for a variant.
Args:
variant: The variant keyword.
Returns:
The base keyword or None if not a variant.
"""
return KEYWORD_VARIANTS.get(variant.lower(), None)
def validate_card_keywords(card_text: str) -> list[dict]:
"""
Validate all keywords found in a card's text.
Args:
card_text: The full text of the card.
Returns:
List of validation results.
"""
validator = KeywordValidator()
return validator.validate_card_text(card_text)
def is_valid_keyword(keyword: str) -> bool:
"""
Quick check if a keyword is valid.
Args:
keyword: The keyword to check.
Returns:
True if the keyword is valid.
"""
return get_keyword_definition(keyword) is not None
def get_keyword_info(keyword: str) -> dict | None:
"""
Get detailed information about a keyword.
Args:
keyword: The keyword to look up.
Returns:
Dictionary with keyword information or None.
"""
return get_keyword_definition(keyword)
File diff suppressed because it is too large Load Diff
+742
View File
@@ -0,0 +1,742 @@
"""
MTG Rules Engine - Keyword Database Module
Contains hardcoded keyword definitions mapped to the official Magic: The Gathering rules.
Based on MTG Rules 2024-06-19 (version 5.3.0+20260722) with custom keywords from Keywords.json.
Data sources:
- /home/user/wall-o/mtg-rules/Keywords.json (keyword names)
- /home/user/wall-o/mtg-rules/rules/GLOSSARY.md (keyword definitions)
- /home/user/wall-o/mtg-rules/rules/rules/7-additional-rules/701-keyword-actions.md
- /home/user/wall-o/mtg-rules/rules/rules/7-additional-rules/702-keyword-abilities.md
"""
# =============================================================================
# KEYWORD ACTIONS (Rule 701)
# =============================================================================
KEYWORD_ACTIONS = {
"activate": {
"rule": "701.2",
"definition": "To activate an activated ability is to put it onto the stack and pay its costs, so that it will eventually resolve and have its effect.",
"type": "action"
},
"attach": {
"rule": "701.3",
"definition": "To attach an Aura, Equipment, or Fortification to an object or player means to take it from where it currently is and put it onto that object or player.",
"type": "action"
},
"behold": {
"rule": "701.4",
"definition": "Reveal a [quality] card from your hand or choose a [quality] permanent you control on the battlefield.",
"type": "action"
},
"cast": {
"rule": "701.5",
"definition": "To cast a spell is to take it from the zone it's in (usually the hand), put it on the stack, and pay its costs, so that it will eventually resolve and have its effect.",
"type": "action"
},
"counter": {
"rule": "701.6",
"definition": "To counter a spell or ability means to cancel it, removing it from the stack. It doesn't resolve and none of its effects occur.",
"type": "action"
},
"create": {
"rule": "701.7",
"definition": "To create one or more tokens with certain characteristics, put the specified number of tokens with the specified characteristics onto the battlefield.",
"type": "action"
},
"destroy": {
"rule": "701.8",
"definition": "To destroy a permanent, move it from the battlefield to its owner's graveyard.",
"type": "action"
},
"discard": {
"rule": "701.9",
"definition": "To discard a card, move it from its owner's hand to that player's graveyard.",
"type": "action"
},
"double": {
"rule": "701.10",
"definition": "Doubling a creature's power and/or toughness creates a continuous effect. This effect modifies that creature's power and/or toughness but doesn't set those characteristics to a specific value.",
"type": "action"
},
"exchange": {
"rule": "701.12",
"definition": "A spell or ability may instruct players to exchange something (for example, life totals or control of two permanents) as part of its resolution.",
"type": "action"
},
"exile": {
"rule": "701.13",
"definition": "To exile an object, move it to the exile zone from wherever it is.",
"type": "action"
},
"fight": {
"rule": "701.14",
"definition": "To have a creature fight another creature means each of those creatures deals damage equal to its power to the other creature.",
"type": "action"
},
"goad": {
"rule": "701.15",
"definition": "A permanent is goaded until the next turn of the controller of the permanent, spell, or ability that caused it to be goaded. A goaded creature attacks each combat if able.",
"type": "action"
},
"investigate": {
"rule": "701.16",
"definition": "To create a Clue artifact token.",
"type": "action"
},
"mill": {
"rule": "701.17",
"definition": "To mill a number of cards, put that many cards from the top of your library into your graveyard.",
"type": "action"
},
"play": {
"rule": "701.18",
"definition": "To play a land means to put it onto the battlefield from the zone it's in. To play a card means to play it as a land or to cast it as a spell.",
"type": "action"
},
"regenerate": {
"rule": "701.19",
"definition": "Regenerate creates a replacement effect that protects a permanent the next time it would be destroyed this turn: remove all damage marked on it and its controller taps it.",
"type": "action"
},
"reveal": {
"rule": "701.20",
"definition": "To reveal a card, show that card to all players for a brief time.",
"type": "action"
},
"sacrifice": {
"rule": "701.21",
"definition": "To sacrifice a permanent, its controller moves it from the battlefield directly to its owner's graveyard.",
"type": "action"
},
"scry": {
"rule": "701.22",
"definition": "To scry N means to look at the top N cards of your library, then put any number of them on the bottom of your library in any order and the rest on top in any order.",
"type": "action"
},
"search": {
"rule": "701.23",
"definition": "To search for a card in a zone, look at all cards in that zone and find a card that matches the given description.",
"type": "action"
},
"shuffle": {
"rule": "701.24",
"definition": "To shuffle a library or a face-down pile of cards, randomize the cards within it so that no player knows their order.",
"type": "action"
},
"surveil": {
"rule": "701.25",
"definition": "To surveil N means to look at the top N cards of your library, then put any number of them into your graveyard and the rest on top of your library in any order.",
"type": "action"
},
"tap": {
"rule": "701.26",
"definition": "To tap a permanent, turn it sideways from an upright position. To untap a permanent, rotate it back to the upright position.",
"type": "action"
},
"transform": {
"rule": "701.27",
"definition": "To transform a permanent, turn it over so its other face is up.",
"type": "action"
},
"convert": {
"rule": "701.28",
"definition": "To convert a permanent, turn it so its other face is up. This follows rules 701.27af.",
"type": "action"
},
"fateseal": {
"rule": "701.29",
"definition": "To fateseal N means to look at the top N cards of an opponent's library, then put any number of them on the bottom of that library in any order and the rest on top in any order.",
"type": "action"
},
"clash": {
"rule": "701.30",
"definition": "To clash, reveal the top card of your library. That player may put that card on the bottom of their library.",
"type": "action"
},
"planeswalk": {
"rule": "701.31",
"definition": "To planeswalk is to put each face-up plane card and phenomenon card on the bottom of its owner's planar deck face down, then move the top card of your planar deck face up.",
"type": "action"
},
"set_in_motion": {
"rule": "701.32",
"definition": "To set a scheme in motion, move it off the top of your scheme deck if it's on top of your scheme deck and turn it face up if it isn't face up.",
"type": "action"
},
"abandon": {
"rule": "701.33",
"definition": "To abandon a scheme, turn it face down and put it on the bottom of its owner's scheme deck.",
"type": "action"
},
"proliferate": {
"rule": "701.34",
"definition": "To proliferate means to choose any number of permanents and/or players that have a counter, then give each one additional counter of each kind that permanent or player already has.",
"type": "action"
},
"detain": {
"rule": "701.35",
"definition": "A permanent is detained until the next turn of the controller of the spell or ability. A detained permanent can't attack or block and its activated abilities can't be activated.",
"type": "action"
},
"populate": {
"rule": "701.36",
"definition": "To populate means to choose a creature token you control and create a token that's a copy of that creature token.",
"type": "action"
},
"monstrosity": {
"rule": "701.37",
"definition": "If this permanent isn't monstrous, put N +1/+1 counters on it. If it becomes monstrous, it stays monstrous until it leaves the battlefield.",
"type": "action"
},
"vote": {
"rule": "701.38",
"definition": "Players vote for one choice from a list of options to determine some aspect of the effect of that spell or ability.",
"type": "action"
},
"bolster": {
"rule": "701.39",
"definition": "Choose a creature you control with the least toughness or tied for least toughness among creatures you control. Put N +1/+1 counters on that creature.",
"type": "action"
},
"manifest": {
"rule": "701.40",
"definition": "To manifest a card, turn it face down. It becomes a 2/2 face-down creature card with ward {2}, no name, no subtypes, and no mana cost. Put that card onto the battlefield face down.",
"type": "action"
},
"support": {
"rule": "701.41",
"definition": "Put a +1/+1 counter on each of up to N other target creatures.",
"type": "action"
},
"meld": {
"rule": "701.42",
"definition": "Meld is a keyword action that appears in an ability on one card in a meld pair. To meld the two cards, put them onto the battlefield with their back faces up and combined.",
"type": "action"
},
"exert": {
"rule": "701.43",
"definition": "To exert a permanent, you choose to have it not untap during your next untap step.",
"type": "action"
},
"explore": {
"rule": "701.44",
"definition": "Reveal the top card of your library. If a land card is revealed, put it into your hand. Otherwise, put a +1/+1 counter on the exploring permanent and may put the revealed card into your graveyard.",
"type": "action"
},
"assemble": {
"rule": "701.45",
"definition": "Unstable set mechanic. Puts Contraptions onto the battlefield.",
"type": "action"
},
"adapt": {
"rule": "701.46",
"definition": "If this permanent has no +1/+1 counters on it, put N +1/+1 counters on it.",
"type": "action"
},
"amass": {
"rule": "701.47",
"definition": "If you don't control an Army creature, create a 0/0 black Army creature token. Choose an Army creature you control. Put N +1/+1 counters on that creature.",
"type": "action"
},
"learn": {
"rule": "701.48",
"definition": "You may discard a card. If you do, draw a card. If you didn't discard a card, you may reveal a Lesson card you own from outside the game and put it into your hand.",
"type": "action"
},
"venture_into_the_dungeon": {
"rule": "701.49",
"definition": "Choose a dungeon card you own from outside the game and put it into the command zone. Put your venture marker on the topmost room.",
"type": "action"
},
"connive": {
"rule": "701.50",
"definition": "Draw a card, then discard a card. If a nonland card is discarded, put a +1/+1 counter on the conniving permanent.",
"type": "action"
},
"open_an_attraction": {
"rule": "701.51",
"definition": "Move the top card of your Attraction deck off the Attraction deck, turn it face up, and put it onto the battlefield under your control.",
"type": "action"
},
"roll_to_visit_your_attractions": {
"rule": "701.52",
"definition": "Roll a six-sided die. If you control one or more Attractions with a number lit up that is equal to that result, each of those Attractions has been 'visited' and its visit ability triggers.",
"type": "action"
},
"incubate": {
"rule": "701.53",
"definition": "Create an Incubator token that enters the battlefield with N +1/+1 counters on it.",
"type": "action"
},
"the_ring_tempts_you": {
"rule": "701.54",
"definition": "Choose a creature you control. That creature becomes your Ring-bearer until another creature becomes your Ring-bearer or another player gains control of it.",
"type": "action"
},
"face_a_villainous_choice": {
"rule": "701.55",
"definition": "Choose [option A] or [option B]. Then all actions in the chosen option are performed.",
"type": "action"
},
"time_travel": {
"rule": "701.56",
"definition": "Choose any number of permanents you control with one or more time counters and/or suspended cards you own in exile with one or more time counters, and, for each of those objects, put a time counter on it or remove a time counter from it.",
"type": "action"
},
"discover": {
"rule": "701.57",
"definition": "Exile cards from the top of your library until you exile a nonland card with mana value N or less. You may cast that card without paying its mana cost if the resulting spell's mana value is less than or equal to N. If you don't cast it, put that card into your hand.",
"type": "action"
},
"cloak": {
"rule": "701.58",
"definition": "To cloak a card, turn it face down. It becomes a 2/2 face-down creature card with ward {2}, no name, no subtypes, and no mana cost. Put that card onto the battlefield face down.",
"type": "action"
},
"collect_evidence": {
"rule": "701.59",
"definition": "Exile any number of cards from your graveyard with total mana value N or greater.",
"type": "action"
},
"suspect": {
"rule": "701.60",
"definition": "A creature becomes suspected. A suspected permanent has menace and can't block. A suspected permanent can't become suspected again.",
"type": "action"
},
"forage": {
"rule": "701.61",
"definition": "Exile three cards from your graveyard or sacrifice a Food.",
"type": "action"
},
"manifest_dread": {
"rule": "701.62",
"definition": "Look at the top two cards of your library. Manifest one of them, then put the cards you looked at that were not manifested into your graveyard.",
"type": "action"
},
"endure": {
"rule": "701.63",
"definition": "Create an N/N white Spirit creature token unless you put N +1/+1 counters on that permanent.",
"type": "action"
},
"harness": {
"rule": "701.64",
"definition": "If this permanent isn't harnessed, it becomes harnessed. Harnessed is a designation permanents can have.",
"type": "action"
},
"airbend": {
"rule": "701.65",
"definition": "Exile one or more permanents and/or spells. For each card exiled this way, for as long as it remains exiled, its owner may cast it by paying {2} rather than paying its mana cost.",
"type": "action"
},
"earthbend": {
"rule": "701.66",
"definition": "Target land you control becomes a 0/0 land creature with haste in addition to its other types. Put N +1/+1 counters on it. When that land dies or is put into exile, return it to the battlefield tapped under your control.",
"type": "action"
},
"waterbend": {
"rule": "701.67",
"definition": "Pay [cost]. For each generic mana in that cost, you may tap an untapped artifact or creature you control rather than pay that mana.",
"type": "action"
},
"blight": {
"rule": "701.68",
"definition": "Put N -1/-1 counters on a creature you control.",
"type": "action"
},
"heal": {
"rule": "701.69",
"definition": "To heal damage already dealt to a permanent, remove that marked damage from that permanent.",
"type": "action"
},
}
# =============================================================================
# KEYWORD ABILITIES (Rule 702)
# =============================================================================
KEYWORD_ABILITIES = {
# Static abilities
"deathtouch": {"rule": "702.2", "type": "static", "definition": "A creature with toughness greater than 0 that's been dealt damage by a source with deathtouch since the last time state-based actions were checked is destroyed as a state-based action."},
"defender": {"rule": "702.3", "type": "static", "definition": "A creature with defender can't attack."},
"double_strike": {"rule": "702.4", "type": "static", "definition": "If at least one attacking or blocking creature has first strike or double strike as the combat damage step begins, the only creatures that assign combat damage in that step are those with first strike or double strike."},
"enchant": {"rule": "702.5", "type": "static", "definition": "Enchant is a static ability, written 'Enchant [object or player].' The enchant ability restricts what an Aura spell can target and what an Aura can enchant."},
"equip": {"rule": "702.6", "type": "activated", "definition": "Equip is an activated ability of Equipment cards. 'Equip [cost]' means '[Cost]: Attach this permanent to target creature you control. Activate only as a sorcery.'"},
"first_strike": {"rule": "702.7", "type": "static", "definition": "First strike is a static ability that modifies the rules for the combat damage step. Creatures with first strike or double strike deal damage in the first combat damage step."},
"flash": {"rule": "702.8", "type": "static", "definition": "Flash is a static ability that functions in any zone from which you could play the card it's on. 'Flash' means 'You may play this card any time you could cast an instant.'"},
"flying": {"rule": "702.9", "type": "evasion", "definition": "Flying is an evasion ability. A creature with flying can't be blocked except by creatures with flying and/or reach."},
"haste": {"rule": "702.10", "type": "static", "definition": "Haste is a static ability. If a creature has haste, it can attack even if it hasn't been controlled by its controller continuously since their most recent turn began."},
"hexproof": {"rule": "702.11", "type": "static", "definition": "Hexproof is a static ability. 'Hexproof' on a permanent means 'This permanent can't be the target of spells or abilities your opponents control.'"},
"indestructible": {"rule": "702.12", "type": "static", "definition": "Indestructible is a static ability. A permanent with indestructible can't be destroyed. Such permanents aren't destroyed by lethal damage, and they ignore the state-based action that checks for lethal damage."},
"intimidate": {"rule": "702.13", "type": "evasion", "definition": "Intimidate is an evasion ability. A creature with intimidate can't be blocked except by artifact creatures and/or creatures that share a color with it."},
"landwalk": {"rule": "702.14", "type": "evasion", "definition": "Landwalk is a generic term for a group of keyword abilities that restrict whether a creature may be blocked. A creature with landwalk can't be blocked as long as the defending player controls at least one land with the specified land type."},
"lifelink": {"rule": "702.15", "type": "static", "definition": "Damage dealt by a source with lifelink causes that source's controller, or its owner if it has no controller, to gain that much life (in addition to any other results that damage causes)."},
"protection": {"rule": "702.16", "type": "static", "definition": "Protection is a static ability, written 'Protection from [quality].' A permanent or player with protection can't be targeted by spells with the stated quality and can't be targeted by abilities from a source with the stated quality."},
"reach": {"rule": "702.17", "type": "evasion", "definition": "Reach is a static ability. A creature with flying can't be blocked except by creatures with flying and/or reach."},
"shroud": {"rule": "702.18", "type": "static", "definition": "Shroud is a static ability. 'Shroud' means 'This permanent or player can't be the target of spells or abilities.'"},
"trample": {"rule": "702.19", "type": "static", "definition": "Trample is a static ability that modifies the rules for assigning an attacking creature's combat damage. The controller of an attacking creature with trample first assigns damage to the creature(s) blocking it. Once all those blocking creatures are assigned lethal damage, any excess damage is assigned as its controller chooses among those blocking creatures and the player, planeswalker, or battle the creature is attacking."},
"vigilance": {"rule": "702.20", "type": "static", "definition": "Vigilance is a static ability that modifies the rules for the declare attackers step. Attacking doesn't cause creatures with vigilance to tap."},
"ward": {"rule": "702.21", "type": "triggered", "definition": "Ward [cost] means 'Whenever this permanent becomes the target of a spell or ability an opponent controls, counter that spell or ability unless that player pays [cost].'"},
"banding": {"rule": "702.22", "type": "static", "definition": "Banding is a static ability that modifies the rules for combat. Creatures with banding can form attacking bands."},
"rampage": {"rule": "702.23", "type": "triggered", "definition": "Rampage N means 'Whenever this creature becomes blocked, it gets +N/+N until end of turn for each creature blocking it beyond the first.'"},
"cumulative_upkeep": {"rule": "702.24", "type": "triggered", "definition": "Cumulative upkeep [cost] means 'At the beginning of your upkeep, if this permanent is on the battlefield, put an age counter on this permanent. Then you may pay [cost] for each age counter on it. If you don't, sacrifice it.'"},
"flanking": {"rule": "702.25", "type": "triggered", "definition": "Flanking means 'Whenever this creature becomes blocked by a creature without flanking, the blocking creature gets -1/-1 until end of turn.'"},
"phasing": {"rule": "702.26", "type": "static", "definition": "Phasing is a static ability that modifies the rules of the untap step. During each player's untap step, before the active player untaps permanents, all phased-in permanents with phasing that player controls 'phase out.' Simultaneously, all phased-out permanents that had phased out under that player's control 'phase in.'"},
"buyback": {"rule": "702.27", "type": "static", "definition": "Buyback [cost] means 'You may pay an additional [cost] as you cast this spell' and 'If the buyback cost was paid, put this spell into its owner's hand instead of into that player's graveyard as it resolves.'"},
"shadow": {"rule": "702.28", "type": "evasion", "definition": "Shadow is an evasion ability. A creature with shadow can't be blocked by creatures without shadow, and a creature without shadow can't be blocked by creatures with shadow."},
"cycling": {"rule": "702.29", "type": "activated", "definition": "Cycling is an activated ability that functions only while the card with cycling is in a player's hand. 'Cycling [cost]' means '[Cost], Discard this card: Draw a card.'"},
"echo": {"rule": "702.30", "type": "triggered", "definition": "Echo [cost] means 'At the beginning of your upkeep, if this permanent came under your control since the beginning of your last upkeep, sacrifice it unless you pay [cost].'"},
"horsemanship": {"rule": "702.31", "type": "evasion", "definition": "Horsemanship is an evasion ability. A creature with horsemanship can't be blocked by creatures without horsemanship. A creature with horsemanship can block a creature with or without horsemanship."},
"fading": {"rule": "702.32", "type": "static", "definition": "Fading N means 'This permanent enters with N fade counters on it' and 'At the beginning of your upkeep, remove a fade counter from this permanent. If you can't, sacrifice the permanent.'"},
"kicker": {"rule": "702.33", "type": "static", "definition": "Kicker [cost] means 'You may pay an additional [cost] as you cast this spell.' A spell has been 'kicked' if its controller declared the intention to pay any of that spell's kicker costs."},
"flashback": {"rule": "702.34", "type": "static", "definition": "Flashback [cost] means 'You may cast this card from your graveyard if the resulting spell is an instant or sorcery spell by paying [cost] rather than paying its mana cost' and 'If the flashback cost was paid, exile this card instead of putting it anywhere else any time it would leave the stack.'"},
"madness": {"rule": "702.35", "type": "static", "definition": "Madness [cost] means 'If a player would discard this card, that player discards it, but exiles it instead of putting it into their graveyard' and 'When this card is exiled this way, its owner may cast it by paying [cost] rather than paying its mana cost.'"},
"fear": {"rule": "702.36", "type": "evasion", "definition": "Fear is an evasion ability. A creature with fear can't be blocked except by artifact creatures and/or black creatures."},
"morph": {"rule": "702.37", "type": "static", "definition": "Morph [cost] means 'You may cast this card as a 2/2 face-down creature with no text, no name, no subtypes, and no mana cost by paying {3} rather than paying its mana cost.'"},
"amplify": {"rule": "702.38", "type": "static", "definition": "As this object enters, reveal any number of cards from your hand that share a creature type with it. This permanent enters with N +1/+1 counters on it for each card revealed this way."},
"provoke": {"rule": "702.39", "type": "triggered", "definition": "Whenever this creature attacks, you may choose to have target creature defending player controls block this creature this combat if able. If you do, untap that creature."},
"storm": {"rule": "702.40", "type": "triggered", "definition": "When you cast this spell, copy it for each other spell that was cast before it this turn. If the spell has any targets, you may choose new targets for the copies."},
"affinity": {"rule": "702.41", "type": "static", "definition": "Affinity for [text] means 'This spell costs {1} less to cast for each [text] you control.'"},
"entwine": {"rule": "702.42", "type": "static", "definition": "Entwine [cost] means 'You may choose all modes of this spell instead of just the number specified. If you do, you pay an additional [cost].'"},
"modular": {"rule": "702.43", "type": "static", "definition": "Modular N means 'This permanent enters with N +1/+1 counters on it' and 'When this permanent is put into a graveyard from the battlefield, you may put a +1/+1 counter on target artifact creature for each +1/+1 counter on this permanent.'"},
"sunburst": {"rule": "702.44", "type": "static", "definition": "As this object enters, ignoring any type-changing effects that would affect it, it enters with a +1/+1 counter on it for each color of mana spent to cast it. Otherwise, it enters with a charge counter on it for each color of mana spent to cast it."},
"bushido": {"rule": "702.45", "type": "triggered", "definition": "Whenever this creature blocks or becomes blocked, it gets +N/+N until end of turn."},
"soulshift": {"rule": "702.46", "type": "triggered", "definition": "When this permanent is put into a graveyard from the battlefield, you may return target Spirit card with mana value N or less from your graveyard to your hand."},
"splice": {"rule": "702.47", "type": "static", "definition": "Splice onto [quality] [cost] means 'You may reveal this card from your hand as you cast a [quality] spell. If you do, that spell gains the text of this card's rules text and you pay [cost] as an additional cost to cast that spell.'"},
"offering": {"rule": "702.48", "type": "static", "definition": "As an additional cost to cast this spell, you may sacrifice a [quality] permanent. If you chose to pay the additional cost, this spell's total cost is reduced by the sacrificed permanent's mana cost, and you may cast this spell any time you could cast an instant."},
"ninjutsu": {"rule": "702.49", "type": "activated", "definition": "Ninjutsu [cost] means '[Cost], Reveal this card from your hand, Return an unblocked attacking creature you control to its owner's hand: Put this card onto the battlefield from your hand tapped and attacking.'"},
"epic": {"rule": "702.50", "type": "static", "definition": "Epic means 'For the rest of the game, you can't cast spells,' and 'At the beginning of each of your upkeeps for the rest of the game, copy this spell except for its epic ability. If the spell has any targets, you may choose new targets for the copy.'"},
"convoke": {"rule": "702.51", "type": "static", "definition": "Convoke means 'For each colored mana in this spell's total cost, you may tap an untapped creature of that color you control rather than pay that mana. For each generic mana in this spell's total cost, you may tap an untapped creature you control rather than pay that mana.'"},
"dredge": {"rule": "702.52", "type": "static", "definition": "As long as you have at least N cards in your library, if you would draw a card, you may instead mill N cards and return this card from your graveyard to your hand."},
"transmute": {"rule": "702.53", "type": "activated", "definition": "Transmute [cost] means '[Cost], Discard this card: Search your library for a card with the same mana value as the discarded card, reveal that card, and put it into your hand. Then shuffle your library. Activate only as a sorcery.'"},
"bloodthirst": {"rule": "702.54", "type": "static", "definition": "Bloodthirst N means 'If an opponent was dealt damage this turn, this permanent enters with N +1/+1 counters on it.'"},
"haunt": {"rule": "702.55", "type": "triggered", "definition": "When this permanent is put into a graveyard from the battlefield, exile it haunting target creature."},
"replicate": {"rule": "702.56", "type": "static", "definition": "As an additional cost to cast this spell, you may pay [cost] any number of times. When you cast this spell, if a replicate cost was paid for it, copy it for each time its replicate cost was paid."},
"forecast": {"rule": "702.57", "type": "activated", "definition": "Forecast — [Activated ability]. The controller of the forecast ability reveals the card with that ability from their hand as the ability is activated. That player plays with that card revealed in their hand until it leaves the player's hand or until a step or phase that isn't an upkeep step begins."},
"graft": {"rule": "702.58", "type": "static", "definition": "This permanent enters with N +1/+1 counters on it and 'Whenever another creature enters, if this permanent has a +1/+1 counter on it, you may move a +1/+1 counter from this permanent onto that creature.'"},
"recover": {"rule": "702.59", "type": "activated", "definition": "When a creature is put into your graveyard from the battlefield, you may pay [cost]. If you do, return this card from your graveyard to your hand. Otherwise, exile this card."},
"ripple": {"rule": "702.60", "type": "triggered", "definition": "When you cast this spell, you may reveal the top N cards of your library, or, if there are fewer than N cards in your library, you may reveal all the cards in your library. If you reveal cards from your library this way, you may cast any of those cards with the same name as this spell without paying their mana costs."},
"split_second": {"rule": "702.61", "type": "static", "definition": "As long as this spell is on the stack, players can't cast other spells or activate abilities that aren't mana abilities."},
"suspend": {"rule": "702.62", "type": "static", "definition": "If you could begin to cast this card by putting it onto the stack from your hand, you may pay [cost] and exile it with N time counters on it. This action doesn't use the stack. At the beginning of your upkeep, if this card is suspended, remove a time counter from it. When the last time counter is removed from this card, if it's exiled, you may play it without paying its mana cost if able."},
"vanishing": {"rule": "702.63", "type": "static", "definition": "This permanent enters with N time counters on it, 'At the beginning of your upkeep, if this permanent has a time counter on it, remove a time counter from it,' and 'When the last time counter is removed from this permanent, sacrifice it.'"},
"absorb": {"rule": "702.64", "type": "static", "definition": "If a source would deal damage to this creature, prevent N of that damage."},
"aura_swap": {"rule": "702.65", "type": "activated", "definition": "You may exchange this permanent with an Aura card in your hand."},
"delve": {"rule": "702.66", "type": "static", "definition": "For each generic mana in this spell's total cost, you may exile a card from your graveyard rather than pay that mana."},
"fortify": {"rule": "702.67", "type": "activated", "definition": "Fortify [cost] means '[Cost]: Attach this Fortification to target land you control. Activate only as a sorcery.'"},
"frenzy": {"rule": "702.68", "type": "triggered", "definition": "Whenever this creature attacks and isn't blocked, it gets +N/+0 until end of turn."},
"gravestorm": {"rule": "702.69", "type": "triggered", "definition": "When you cast this spell, copy it for each permanent that was put into a graveyard from the battlefield this turn. If the spell has any targets, you may choose new targets for the copies."},
"poisonous": {"rule": "702.70", "type": "triggered", "definition": "Whenever this creature deals combat damage to a player, that player gets N poison counters."},
"transfigure": {"rule": "702.71", "type": "activated", "definition": "Transfigure [cost] means '[Cost], Sacrifice this permanent: Search your library for a creature card with the same mana value as this permanent and put it onto the battlefield. Then shuffle your library. Activate only as a sorcery.'"},
"champion": {"rule": "702.72", "type": "triggered", "definition": "When this permanent enters, sacrifice it unless you exile another [object] you control. When this permanent leaves the battlefield, return the exiled card to the battlefield under its owner's control."},
"changeling": {"rule": "702.73", "type": "static", "definition": "Changeling is a characteristic-defining ability. 'Changeling' means 'This object is every creature type.'"},
"evoke": {"rule": "702.74", "type": "static", "definition": "You may cast this card by paying [cost] rather than paying its mana cost. When this permanent enters, if its evoke cost was paid, its controller sacrifices it."},
"hideaway": {"rule": "702.75", "type": "triggered", "definition": "When this permanent enters, look at the top N cards of your library. Exile one of them face down and put the rest on the bottom of your library in a random order. The exiled card gains 'The player who controls the permanent that exiled this card may look at this card in the exile zone.'"},
"prowl": {"rule": "702.76", "type": "static", "definition": "You may pay [cost] rather than pay this spell's mana cost if a player was dealt combat damage this turn by a source that, at the time it dealt that damage, was under your control and had any of this spell's creature types."},
"reinforce": {"rule": "702.77", "type": "activated", "definition": "Reinforce N—[cost] means '[Cost], Discard this card: Put N +1/+1 counters on target creature.'"},
"conspire": {"rule": "702.78", "type": "static", "definition": "As an additional cost to cast this spell, you may tap two untapped creatures you control that each share a color with it. When you cast this spell, if its conspire cost was paid, copy it. If the spell has any targets, you may choose new targets for the copy."},
"persist": {"rule": "702.79", "type": "triggered", "definition": "When this permanent is put into a graveyard from the battlefield, if it had no -1/-1 counters on it, return it to the battlefield under its owner's control with a -1/-1 counter on it."},
"wither": {"rule": "702.80", "type": "static", "definition": "Damage dealt to a creature by a source with wither isn't marked on that creature. Rather, it causes that source's controller to put that many -1/-1 counters on that creature."},
"retrace": {"rule": "702.81", "type": "activated", "definition": "Retrace means 'You may cast this card from your graveyard by discarding a land card as an additional cost to cast it.'"},
"devour": {"rule": "702.82", "type": "static", "definition": "As this object enters, you may sacrifice any number of creatures. This permanent enters with N +1/+1 counters on it for each creature sacrificed this way."},
"exalted": {"rule": "702.83", "type": "triggered", "definition": "Whenever a creature you control attacks alone, that creature gets +1/+1 until end of turn."},
"unearth": {"rule": "702.84", "type": "activated", "definition": "Unearth [cost] means '[Cost]: Return this card from your graveyard to the battlefield. It gains haste. Exile it at the beginning of the next end step. If it would leave the battlefield, exile it instead of putting it anywhere else. Activate only as a sorcery.'"},
"cascade": {"rule": "702.85", "type": "triggered", "definition": "When you cast this spell, exile cards from the top of your library until you exile a nonland card with mana value less than this spell's mana value. You may cast that card without paying its mana cost if the resulting spell's mana value is less than this spell's mana value. Then put all cards exiled this way that weren't cast on the bottom of your library in a random order."},
"annihilator": {"rule": "702.86", "type": "triggered", "definition": "Whenever this creature attacks, defending player sacrifices N permanents."},
"level_up": {"rule": "702.87", "type": "activated", "definition": "Level up [cost] means '[Cost]: Put a level counter on this permanent. Activate only as a sorcery.'"},
"rebound": {"rule": "702.88", "type": "static", "definition": "If this spell was cast from your hand, instead of putting it into your graveyard as it resolves, exile it and, at the beginning of your next upkeep, you may cast this card from exile without paying its mana cost."},
"umbra_armor": {"rule": "702.89", "type": "static", "definition": "If enchanted permanent would be destroyed, instead remove all damage marked on it and destroy this Aura."},
"infect": {"rule": "702.90", "type": "static", "definition": "Damage dealt to a player by a source with infect doesn't cause that player to lose life. Rather, it causes that source's controller to give the player that many poison counters. Damage dealt to a creature by a source with infect isn't marked on that creature. Rather, it causes that source's controller to put that many -1/-1 counters on that creature."},
"battle_cry": {"rule": "702.91", "type": "triggered", "definition": "Whenever this creature attacks, each other attacking creature gets +1/+0 until end of turn."},
"living_weapon": {"rule": "702.92", "type": "triggered", "definition": "When this Equipment enters, create a 0/0 black Phyrexian Germ creature token, then attach this Equipment to it."},
"undying": {"rule": "702.93", "type": "triggered", "definition": "When this permanent is put into a graveyard from the battlefield, if it had no +1/+1 counters on it, return it to the battlefield under its owner's control with a +1/+1 counter on it."},
"miracle": {"rule": "702.94", "type": "static", "definition": "If a player would discard this card, that player discards it, but exiles it instead of putting it into their graveyard. When this card is exiled this way, its owner may cast it by paying [cost] rather than paying its mana cost."},
"soulbond": {"rule": "702.95", "type": "triggered", "definition": "When this creature enters, if you control both this creature and another creature and both are unpaired, you may pair this creature with another unpaired creature you control for as long as both remain creatures on the battlefield under your control."},
"overload": {"rule": "702.96", "type": "static", "definition": "You may choose to pay [cost] rather than pay this spell's mana cost. If you chose to pay this spell's overload cost, change its text by replacing all instances of the word 'target' with the word 'each.'"},
"scavenge": {"rule": "702.97", "type": "activated", "definition": "Scavenge [cost] means '[Cost], Exile this card from your graveyard: Put a number of +1/+1 counters equal to the power of the card you exiled on target creature. Activate only as a sorcery.'"},
"unleash": {"rule": "702.98", "type": "static", "definition": "You may have this permanent enter with an additional +1/+1 counter on it. This permanent can't block as long as it has a +1/+1 counter on it."},
"cipher": {"rule": "702.99", "type": "static", "definition": "If this spell is represented by a card, you may exile this card encoded on a creature you control. For as long as this card is encoded on that creature, that creature has 'Whenever this creature deals combat damage to a player, you may copy the encoded card and you may cast the copy without paying its mana cost.'"},
"evolve": {"rule": "702.100", "type": "triggered", "definition": "Whenever a creature you control enters, if that creature's power is greater than this creature's power and/or that creature's toughness is greater than this creature's toughness, put a +1/+1 counter on this creature."},
"extort": {"rule": "702.101", "type": "triggered", "definition": "Whenever you cast a spell, you may pay {W/B}. If you do, each opponent loses 1 life and you gain life equal to the total life lost this way."},
"fuse": {"rule": "702.102", "type": "static", "definition": "You may choose to cast both halves of a split card rather than choose one half. The resulting spell is a fused split spell."},
"bestow": {"rule": "702.103", "type": "static", "definition": "As you cast this spell, you may choose to cast it bestowed. If you do, you pay [cost] rather than its mana cost. As a spell cast bestowed is put onto the stack, it becomes an Aura enchantment and gains enchant creature."},
"dethrone": {"rule": "702.105", "type": "triggered", "definition": "Whenever this creature attacks the player with the most life or tied for most life, put a +1/+1 counter on this creature."},
"hidden_agenda": {"rule": "702.106", "type": "static", "definition": "As you put this conspiracy card into the command zone, turn it face down and secretly choose a card name."},
"double_agenda": {"rule": "702.106", "type": "static", "definition": "As you put a conspiracy card with double agenda into the command zone, you secretly name two different cards rather than one."},
"outlast": {"rule": "702.107", "type": "activated", "definition": "Outlast [cost] means '[Cost], {T}: Put a +1/+1 counter on this creature. Activate only as a sorcery.'"},
"prowess": {"rule": "702.108", "type": "triggered", "definition": "Whenever you cast a noncreature spell, this creature gets +1/+1 until end of turn."},
"dash": {"rule": "702.109", "type": "static", "definition": "You may cast this card by paying [cost] rather than paying its mana cost. If this spell's dash cost was paid, return the permanent this spell becomes to its owner's hand at the beginning of the next end step. As long as this permanent's dash cost was paid, it has haste."},
"exploit": {"rule": "702.110", "type": "triggered", "definition": "When this creature enters, you may sacrifice a creature."},
"menace": {"rule": "702.111", "type": "evasion", "definition": "A creature with menace can't be blocked except by two or more creatures."},
"renown": {"rule": "702.112", "type": "triggered", "definition": "Whenever this creature deals combat damage to a player, if it isn't renowned, put N +1/+1 counters on it and it becomes renowned."},
"awaken": {"rule": "702.113", "type": "static", "definition": "You may pay [cost] rather than pay this spell's mana cost. If this spell's awaken cost was paid, put N +1/+1 counters on target land you control. That land becomes a 0/0 Elemental creature with haste. It's still a land."},
"devoid": {"rule": "702.114", "type": "static", "definition": "Devoid is a characteristic-defining ability. 'Devoid' means 'This object is colorless.'"},
"ingest": {"rule": "702.115", "type": "triggered", "definition": "Whenever this creature deals combat damage to a player, that player exiles the top card of their library."},
"myriad": {"rule": "702.116", "type": "triggered", "definition": "Whenever this creature attacks, for each opponent other than defending player, you may create a token that's a copy of this creature that's tapped and attacking that player or a planeswalker they control. If one or more tokens are created this way, exile the tokens at end of combat."},
"surge": {"rule": "702.117", "type": "static", "definition": "You may pay [cost] rather than pay this spell's mana cost if you or one of your teammates has cast another spell this turn."},
"skulk": {"rule": "702.118", "type": "evasion", "definition": "A creature with skulk can't be blocked by creatures with greater power."},
"emerge": {"rule": "702.119", "type": "static", "definition": "You may cast this spell by paying [cost] and sacrificing a creature rather than paying its mana cost. If you chose to pay this spell's emerge cost, its total cost is reduced by an amount of generic mana equal to the sacrificed creature's mana value."},
"escalate": {"rule": "702.120", "type": "static", "definition": "Choose one or more modes. As an additional cost to cast this spell, pay the costs associated with those modes."},
"train": {"rule": "702.149", "type": "triggered", "definition": "Whenever this creature and at least one other creature with power greater than this creature's power attack, put a +1/+1 counter on this creature."},
"completed": {"rule": "702.150", "type": "static", "definition": "If this permanent would enter with one or more loyalty counters on it and the player who cast it chose to pay life for any part of its cost represented by Phyrexian mana symbols, it instead enters the battlefield with that many loyalty counters minus two for each of those mana symbols."},
"reconfigure": {"rule": "702.151", "type": "activated", "definition": "Reconfigure [cost] means '[Cost]: Attach this permanent to another target creature you control. Activate only as a sorcery.'"},
"blitz": {"rule": "702.152", "type": "static", "definition": "You may cast this card by paying [cost] rather than paying its mana cost. If this spell's blitz cost was paid, sacrifice the permanent this spell becomes at the beginning of the next end step. As long as this permanent's blitz cost was paid, it has haste and 'When this permanent is put into a graveyard from the battlefield, draw a card.'"},
"casualty": {"rule": "702.153", "type": "static", "definition": "As an additional cost to cast this spell, you may sacrifice a creature with power N or greater. When you cast this spell, if a casualty cost was paid for it, copy it. If the spell has any targets, you may choose new targets for the copy."},
"enlist": {"rule": "702.154", "type": "static", "definition": "As this creature attacks, you may tap up to one untapped creature you control that you didn't choose to attack with and that either has haste or has been under your control continuously since this turn began. When you do, this creature gets +X/+0 until end of turn, where X is the tapped creature's power."},
"foretell": {"rule": "702.143", "type": "static", "definition": "Any time a player has priority during their turn, that player may pay {2} and exile a card with foretell from their hand face down. That player may look at that card as long as it remains in exile and it may be cast for any foretell cost it has after the turn it became a foretold card has ended."},
"demonstrate": {"rule": "702.144", "type": "triggered", "definition": "When you cast this spell, you may copy it and you may choose an opponent. That player copies the spell and may choose new targets for that copy."},
"daybound": {"rule": "702.145", "type": "static", "definition": "If it is night and this permanent is represented by a double-faced card, it enters transformed. As it becomes night, if this permanent is front face up, transform it. This permanent can't transform except due to its daybound ability."},
"nightbound": {"rule": "702.145", "type": "static", "definition": "As it becomes day, if this permanent is back face up, transform it. This permanent can't transform except due to its nightbound ability."},
"disturb": {"rule": "702.146", "type": "static", "definition": "Disturb [cost] means 'You may cast this card transformed from your graveyard by paying [cost] rather than its mana cost.'"},
"decayed": {"rule": "702.147", "type": "static", "definition": "This creature can't block and 'When this creature attacks, sacrifice it at end of combat.'"},
"cleave": {"rule": "702.148", "type": "static", "definition": "You may cast this spell by paying [cost] rather than paying its mana cost. If this spell's cleave cost was paid, change its text by removing all text found within square brackets in the spell's rules text."},
"firebending": {"rule": "702.189", "type": "triggered", "definition": "Whenever this creature attacks, add N {R}. Until end of combat, you don't lose this mana as steps and phases end."},
"sneak": {"rule": "702.190", "type": "static", "definition": "Any time you could cast an instant during your declare blockers step, you may cast this spell by paying [cost] and returning an unblocked creature you control to its owner's hand rather than paying this spell's mana cost."},
"increment": {"rule": "702.191", "type": "triggered", "definition": "Whenever you cast a spell, if this permanent is a creature and the amount of mana spent to cast that spell is greater than this creature's power or this creature's toughness, put a +1/+1 counter on this creature."},
"paradigm": {"rule": "702.192", "type": "static", "definition": "If this is the first time a spell you control with this spell's name has resolved this game, at the beginning of each of your precombat main phases for the rest of the game, create a copy of this object in exile. You may cast the copy without paying its mana cost."},
"power_up": {"rule": "702.193", "type": "activated", "definition": "Power-up — [Cost]: [Effect]. If this permanent entered this turn, this ability's cost is reduced by this permanent's mana cost. Activate this ability only once."},
"teamwork": {"rule": "702.194", "type": "static", "definition": "As an additional cost to cast this spell, you may tap any number of creatures you control with total power N or more."},
"web_slinging": {"rule": "702.188", "type": "static", "definition": "You may cast this spell by paying [cost] and returning a tapped creature you control to its owner's hand rather than paying its mana cost."},
"firebending": {"rule": "702.189", "type": "triggered", "definition": "Whenever this creature attacks, add N {R}. Until end of combat, you don't lose this mana as steps and phases end."},
"start_your_engines": {"rule": "702.179", "type": "static", "definition": "If a player controls a permanent with start your engines! and that player has no speed, their speed becomes 1. This is a state-based action."},
"max_speed": {"rule": "702.178", "type": "static", "definition": "As long as your speed is 4, this object has '[Ability].'"},
"harmonize": {"rule": "702.180", "type": "static", "definition": "You may cast this card from your graveyard by paying [cost] and tapping up to one untapped creature you control rather than paying this spell's mana cost. If you cast this spell using its harmonize ability, its total cost is reduced by an amount of generic mana equal to the tapped creature's power."},
"mobilize": {"rule": "702.181", "type": "triggered", "definition": "Whenever this creature attacks, create N 1/1 red Warrior creature tokens. Those tokens enter tapped and attacking. Sacrifice them at the beginning of the next end step."},
"job_select": {"rule": "702.182", "type": "triggered", "definition": "When this Equipment enters, create a 1/1 colorless Hero creature token, then attach this Equipment to it."},
"tiered": {"rule": "702.183", "type": "static", "definition": "Choose one. As an additional cost to cast this spell, pay the cost associated with that mode."},
"station": {"rule": "702.184", "type": "activated", "definition": "Station means 'Tap another untapped creature you control: Put a number of charge counters on this permanent equal to the tapped creature's power. Activate only as a sorcery.'"},
"infinity": {"rule": "702.186", "type": "static", "definition": "As long as this permanent is harnessed, it has [ability]."},
"mayhem": {"rule": "702.187", "type": "static", "definition": "As long as you discarded this card this turn, you may cast it from your graveyard by paying [cost] rather than paying its mana cost."},
"training": {"rule": "702.149", "type": "triggered", "definition": "Whenever this creature and at least one other creature with power greater than this creature's power attack, put a +1/+1 counter on this creature."},
}
# =============================================================================
# ABILITY WORDS (No rules meaning - flavor words)
# =============================================================================
ABILITY_WORDS = [
"adamant", "addendum", "alliance", "battalion", "bloodrush", "celebration",
"channel", "chroma", "cohort", "constellation", "converge", "corrupted",
"council's dilemma", "coven", "covercast", "delirium", "descend", "disappear",
"domain", "eerie", "eminence", "enrage", "fateful hour", "fathomless descent",
"ferocious", "flurry", "formidable", "grandeur", "hellbent", "hero's reward",
"heroic", "imprint", "infusion", "inspired", "join forces", "kinfall", "kinship",
"landfall", "landship", "legacy", "lieutenant", "magecraft", "metalcraft",
"morbid", "opus", "pack tactics", "paradox", "parley", "radiance", "raid",
"rally", "renew", "repartee", "revolt", "secret council", "spell mastery",
"start your engines!", "strive", "survival", "sweep", "tempting offer",
"threshold", "underdog", "undergrowth", "valiant", "vivid", "void",
"will of the planeswalkers", "will of the council"
]
# =============================================================================
# KEYWORD VARIANTS (Special forms of keywords)
# =============================================================================
KEYWORD_VARIANTS = {
"megamorph": {"base": "morph", "description": "A variant of the morph ability that puts a +1/+1 counter on the creature as it turns face up."},
"basic_landcycling": {"base": "cycling", "description": "Typecycling where you search for a basic land card."},
"forestcycling": {"base": "cycling", "description": "Typecycling where you search for a forest card."},
"mountaincycling": {"base": "cycling", "description": "Typecycling where you search for a mountain card."},
"islandcycling": {"base": "cycling", "description": "Typecycling where you search for an island card."},
"swampcycling": {"base": "cycling", "description": "Typecycling where you search for a swamp card."},
"plainscycling": {"base": "cycling", "description": "Typecycling where you search for a plains card."},
"slivercycling": {"base": "cycling", "description": "Typecycling where you search for a sliver creature card."},
"typecycling": {"base": "cycling", "description": "A variant of the cycling ability where you search for a card of a specified type."},
"hexproof_from": {"base": "hexproof", "description": "Hexproof that only applies to a specific quality (e.g., 'hexproof from black')."},
"protection_from": {"base": "protection", "description": "Protection that only applies to a specific quality (e.g., 'protection from black')."},
"partner_with": {"base": "partner", "description": "Partner variant that works even outside of the Commander variant to help two cards reach the battlefield together."},
"choose_a_background": {"base": "partner", "description": "Partner variant that lets two legendary permanent cards be your commander if one has choose a Background and the other is a Background enchantment."},
"doctor's_companion": {"base": "partner", "description": "Partner variant that lets two legendary creature cards be your commander if one has Doctor's companion and the other is a Time Lord Doctor."},
"basic_landcycling": {"base": "cycling", "description": "Typecycling where you search for a basic land card."},
"forestcycling": {"base": "cycling", "description": "Typecycling where you search for a forest card."},
"mountaincycling": {"base": "cycling", "description": "Typecycling where you search for a mountain card."},
"islandcycling": {"base": "cycling", "description": "Typecycling where you search for an island card."},
"swampcycling": {"base": "cycling", "description": "Typecycling where you search for a swamp card."},
"plainscycling": {"base": "cycling", "description": "Typecycling where you search for a plains card."},
"slivercycling": {"base": "cycling", "description": "Typecycling where you search for a sliver creature card."},
"forestwalk": {"base": "landwalk", "description": "Landwalk variant for forest."},
"islandwalk": {"base": "landwalk", "description": "Landwalk variant for island."},
"mountainwalk": {"base": "landwalk", "description": "Landwalk variant for mountain."},
"swampwalk": {"base": "landwalk", "description": "Landwalk variant for swamp."},
"plainswalk": {"base": "landwalk", "description": "Landwalk variant for plains."},
"nonbasic_landwalk": {"base": "landwalk", "description": "Landwalk variant for nonbasic lands."},
"snow_swampwalk": {"base": "landwalk", "description": "Landwalk variant for snow swamp."},
"artifact_landwalk": {"base": "landwalk", "description": "Landwalk variant for artifact lands."},
"snow_forestwalk": {"base": "landwalk", "description": "Landwalk variant for snow forest."},
"snow_islandwalk": {"base": "landwalk", "description": "Landwalk variant for snow island."},
"snow_mountainwalk": {"base": "landwalk", "description": "Landwalk variant for snow mountain."},
"snow_plainswalk": {"base": "landwalk", "description": "Landwalk variant for snow plains."},
"snow_swampwalk": {"base": "landwalk", "description": "Landwalk variant for snow swamp."},
}
# =============================================================================
# KEYWORD TYPES
# =============================================================================
KEYWORD_TYPES = {
"static": {
"description": "A permanent effect that is always active. The object with a static ability has the effect all the time.",
"examples": ["flying", "haste", "deathtouch", "indestructible"]
},
"triggered": {
"description": "An effect that triggers in response to a game event. The effect is put on the stack and resolves like any other spell or ability.",
"examples": ["rampage", "squad", "storm", "cascade"]
},
"activated": {
"description": "An effect that a player can choose to activate. Activated abilities have an activation cost and are activated like spells.",
"examples": ["cycling", "sacrifice", "evolve", "spend"]
},
"evasion": {
"description": "A keyword ability that restricts how a creature may be blocked or which creatures it may block.",
"examples": ["flying", "fear", "hexproof", "shroud", "trample"]
}
}
# =============================================================================
# RULES REFERENCE INDEX
# =============================================================================
RULES_INDEX = {
"1-game-concepts": "General rules about playing Magic",
"2-parts-of-a-card": "Card structure: name, mana cost, types, text box, etc.",
"3-card-types": "Card types: creature, spell, land, enchantment, etc.",
"4-zones": "Game zones: battlefield, stack, hand, library, graveyard, exile.",
"5-turn-structure": "Turn phases: untap, draw, main, combat, end.",
"6-spells-abilities-and-effects": "How spells and abilities work: casting, resolving, targeting.",
"7-additional-rules": "Additional rules: keyword actions (701), keyword abilities (702).",
"8-multiplayer-rules": "Multiplayer game rules.",
"9-casual-variants": "Casual variants: Commander, Vanguard, etc."
}
# =============================================================================
# UTILITY FUNCTIONS
# =============================================================================
def get_keyword_definition(keyword: str) -> dict | None:
"""
Get the definition and metadata for a keyword.
Args:
keyword: The keyword name (case-insensitive).
Returns:
Dictionary with keyword information or None if not found.
"""
keyword = keyword.lower().replace(" ", "_")
# Check ability words first
if keyword in [w.lower() for w in ABILITY_WORDS]:
return {
"keyword": keyword,
"type": "ability_word",
"definition": "An italicized word with no rules meaning that ties together abilities on different cards that have similar functionality. See rule 207.2c.",
"rule": "207.2c",
"has_definition": False,
}
# Check keyword abilities
if keyword in KEYWORD_ABILITIES:
ability = KEYWORD_ABILITIES[keyword]
return {
"keyword": keyword,
"type": "keyword_ability",
"definition": ability["definition"],
"rule": ability["rule"],
"ability_type": ability["type"],
"has_definition": True,
}
# Check keyword actions
if keyword in KEYWORD_ACTIONS:
action = KEYWORD_ACTIONS[keyword]
return {
"keyword": keyword,
"type": "keyword_action",
"definition": action["definition"],
"rule": action["rule"],
"has_definition": True,
}
return None
def get_all_keywords() -> list[str]:
"""Get a complete list of all keywords (actions, abilities, and ability words)."""
keywords = set()
keywords.update(KEYWORD_ACTIONS.keys())
keywords.update(KEYWORD_ABILITIES.keys())
keywords.update(ABILITY_WORDS)
return sorted(keywords)
def get_keyword_by_rule(rule: str) -> list[str]:
"""
Get all keywords defined in a specific rule section.
Args:
rule: Rule section (e.g., "701.2" or "702.9").
Returns:
List of keyword names.
"""
keywords = []
# Check keyword actions
for kw, data in KEYWORD_ACTIONS.items():
if data["rule"] == rule:
keywords.append(kw)
# Check keyword abilities
for kw, data in KEYWORD_ABILITIES.items():
if data["rule"] == rule:
keywords.append(kw)
return keywords
def search_keywords(query: str) -> list[dict]:
"""
Search for keywords matching a query string.
Args:
query: Search query (case-insensitive).
Returns:
List of matching keyword dictionaries.
"""
query = query.lower()
results = []
for kw, data in KEYWORD_ABILITIES.items():
if query in kw.lower() or query in data["definition"].lower():
results.append({**data, "keyword": kw})
for kw, data in KEYWORD_ACTIONS.items():
if query in kw.lower() or query in data["definition"].lower():
results.append({**data, "keyword": kw})
for kw in ABILITY_WORDS:
if query in kw.lower():
results.append({
"keyword": kw,
"type": "ability_word",
"definition": "An italicized word with no rules meaning that ties together abilities on different cards that have similar functionality.",
"rule": "207.2c",
"has_definition": False,
})
return results
if __name__ == "__main__":
# Quick test
print("Total keywords:", len(get_all_keywords()))
print("\nSample keywords:")
for kw in ["flying", "haste", "deathtouch", "destroy", "exile"]:
result = get_keyword_definition(kw)
if result:
print(f" {kw}: {result['type']} (Rule {result['rule']})")
else:
print(f" {kw}: NOT FOUND")
+845
View File
@@ -0,0 +1,845 @@
"""
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()
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""Tests for the Magic: The Gathering Rules Engine"""
import sys
import os
# Add parent directory to path for importing mtg_rules_engine
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mtg_rules_engine import (
ABILITY_WORDS,
KEYWORD_ACTIONS,
KEYWORD_ABILITIES,
get_all_keywords,
get_keyword_info,
is_valid_keyword,
get_keyword_type,
KeywordValidator,
RulesEngine,
)
def test_keyword_database():
"""Test the keyword database is properly populated."""
print("=" * 60)
print("TEST: Keyword Database")
print("=" * 60)
# Test ability words
assert len(ABILITY_WORDS) > 0, "Ability words should not be empty"
assert "adamant" in ABILITY_WORDS, "adamant should be in ability words"
print(f"{len(ABILITY_WORDS)} ability words loaded")
# Test keyword actions
assert len(KEYWORD_ACTIONS) > 0, "Keyword actions should not be empty"
assert "fly" not in KEYWORD_ACTIONS and "flying" in KEYWORD_ABILITIES, "flying should be a keyword ability"
assert "cast" in KEYWORD_ACTIONS, "cast should be a keyword action"
assert "destroy" in KEYWORD_ACTIONS, "destroy should be a keyword action"
print(f"{len(KEYWORD_ACTIONS)} keyword actions loaded")
# Test keyword abilities
assert len(KEYWORD_ABILITIES) > 0, "Keyword abilities should not be empty"
assert "flying" in KEYWORD_ABILITIES, "flying should be a keyword ability"
assert "haste" in KEYWORD_ABILITIES, "haste should be a keyword ability"
assert "deathtouch" in KEYWORD_ABILITIES, "deathtouch should be a keyword ability"
assert "trample" in KEYWORD_ABILITIES, "trample should be a keyword ability"
assert "hexproof" in KEYWORD_ABILITIES, "hexproof should be a keyword ability"
assert "flying" in KEYWORD_ABILITIES, "flying should be a keyword ability"
print(f"{len(KEYWORD_ABILITIES)} keyword abilities loaded")
# Test get_all_keywords
all_keywords = get_all_keywords()
assert len(all_keywords) > 0, "get_all_keywords should return keywords"
print(f"{len(all_keywords)} total keywords loaded")
# Test get_keyword_info
info = get_keyword_info("flying")
assert info is not None, "get_keyword_info should return info"
assert "rule" in info, "Keyword info should have 'rule'"
assert "definition" in info, "Keyword info should have 'definition'"
print(f" ✓ Keyword info for 'flying': rule={info['rule'][:40]}...")
# Test get_keyword_type
type_flying = get_keyword_type("flying")
assert type_flying == "keyword_ability", f"Expected keyword_ability, got {type_flying}"
type_cast = get_keyword_type("cast")
assert type_cast == "keyword_action", f"Expected keyword_action, got {type_cast}"
print(f" ✓ Keyword type for 'flying': {type_flying}")
print(f" ✓ Keyword type for 'cast': {type_cast}")
print(" ✓ All keyword database tests passed!\n")
def test_keyword_validator():
"""Test the keyword validator."""
print("=" * 60)
print("TEST: Keyword Validator")
print("=" * 60)
validator = KeywordValidator()
# Test is_valid_keyword
assert validator.is_valid_keyword("flying"), "flying should be valid"
assert validator.is_valid_keyword("haste"), "haste should be valid"
assert not validator.is_valid_keyword("notarealkeyword"), "notarealkeyword should be invalid"
print(" ✓ is_valid_keyword works correctly")
# Test find_keywords_in_text
text = "Flying creatures can't be blocked except by flying creatures with flying abilities."
found = validator.find_keywords_in_text(text)
assert "flying" in found, "flying should be found"
assert found["flying"] == 3, f"Expected 3 'flying', got {found['flying']}"
print(f" ✓ find_keywords_in_text found {found}")
# Test validate_card_text
card_text = "Flying, trample. Haste. When this creature attacks, it deals extra damage."
errors = validator.validate_card_text(card_text)
# No errors expected for valid keywords
print(f" ✓ validate_card_text: {len(errors)} errors (expected 0)")
# Test check_for_misspelled_keywords
misspelled = validator.check_for_misspelled_keywords("I want to fly with this haste creature.")
# "fly" should suggest "flying"
print(f" ✓ check_for_misspelled_keywords found {len(misspelled)} potential misspellings")
# Test export_keywords
exported = validator.export_keywords()
assert "total_keywords" in exported, "export_keywords should have total_keywords"
assert "ability_words" in exported, "export_keywords should have ability_words"
assert "keyword_actions" in exported, "export_keywords should have keyword_actions"
assert "keyword_abilities" in exported, "export_keywords should have keyword_abilities"
print(f" ✓ export_keywords has all expected fields")
print(" ✓ All keyword validator tests passed!\n")
def test_rules_engine():
"""Test the rules engine."""
print("=" * 60)
print("TEST: Rules Engine")
print("=" * 60)
engine = RulesEngine()
# Test validate_card with a valid creature
valid_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"],
"abilities": ["flying", "trample"],
}
errors = engine.validate_card(valid_card)
# Some errors expected (text analysis, etc.)
print(f" ✓ validate_card returned {len(errors)} errors")
# Test validate_action with a valid attack
valid_attack = {
"type": "attack",
"attacker": {
"name": "Garruk the Wreathshaper",
"type": "CREATURE",
"power": 6,
"toughness": 6,
"abilities": ["flying", "trample"],
},
"blocking_creatures": [
{
"name": "Garruk the Wreathshaper",
"type": "CREATURE",
"power": 6,
"toughness": 6,
"abilities": ["flying", "trample"],
}
],
}
errors = engine.validate_action(valid_attack)
# Some errors expected (flying creature can't be blocked)
print(f" ✓ validate_action returned {len(errors)} errors")
# Test get_rule_text
rule = engine.get_rule_text("702.9")
assert rule is not None, "get_rule_text should return a result"
print(f" ✓ get_rule_text returned: {rule[:80]}...")
# Test get_keyword_info_summary
summary = engine.get_keyword_info_summary("flying")
assert summary is not None, "get_keyword_info_summary should return a summary"
assert summary["keyword"] == "flying", "Summary should have correct keyword"
assert summary["rule"] is not None, "Summary should have rule"
print(f" ✓ get_keyword_info_summary works: {summary}")
# Test search_keywords
results = engine.search_keywords("flying")
assert len(results) > 0, "search_keywords should find matches"
print(f" ✓ search_keywords found {len(results)} matches for 'flying'")
# Test validate_game_state
valid_state = {
"players": [
{
"name": "Player 1",
"hand": [],
"creatures": [],
},
{
"name": "Player 2",
"hand": [],
"creatures": [],
},
],
"zones": {
"stack": [],
},
}
errors = engine.validate_game_state(valid_state)
# No errors expected for valid state
print(f" ✓ validate_game_state returned {len(errors)} errors")
print(" ✓ All rules engine tests passed!\n")
def test_integration():
"""Integration test with the full pipeline."""
print("=" * 60)
print("TEST: Integration")
print("=" * 60)
# End-to-end test: Create a card, validate it, analyze its keywords
engine = RulesEngine()
card = {
"name": "Mighty Hero",
"text": "Flying, trample, trample over planeswalkers. Haste. When this creature enters, create a 1/1 token.",
"type": "CREATURE",
"power_toughness": (4, 4),
"color": ["RED"],
"abilities": ["flying", "trample", "haste"],
}
# Validate card
errors = engine.validate_card(card)
print(f" Card validation: {len(errors)} errors")
# Find keywords in card text
keywords = engine.validator.find_keywords_in_text(card["text"])
print(f" Keywords found in card text: {list(keywords.keys())}")
# Get keyword info for each found keyword
for keyword in keywords:
info = engine.get_keyword_info_summary(keyword)
print(f" - {keyword}: {info.get('rule', 'N/A')}")
# Validate an attack
attack = {
"type": "attack",
"attacker": {
"name": "Mighty Hero",
"type": "CREATURE",
"power": 4,
"toughness": 4,
"abilities": ["flying", "trample"],
},
"blocking_creatures": [
{
"name": "Opponent's Flying Creature",
"type": "CREATURE",
"power": 3,
"toughness": 3,
"abilities": ["flying"],
}
],
}
errors = engine.validate_action(attack)
print(f" Attack validation: {len(errors)} errors")
for error in errors:
print(f" - {error['message']}")
print(" ✓ Integration test passed!\n")
if __name__ == "__main__":
print("\n" + "=" * 60)
print("Magic: The Gathering Rules Engine - Test Suite")
print("=" * 60 + "\n")
test_keyword_database()
test_keyword_validator()
test_rules_engine()
test_integration()
print("=" * 60)
print("ALL TESTS PASSED!")
print("=" * 60)
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
MTG Rules Update Check Script
This script checks for updates to the MTG rules repository and applies them
if available. It's designed to be run weekly via cron or manually.
Usage:
python update_check.py [--check] [--apply] [--scan] [--weekly]
"""
import sys
import os
import json
import logging
from datetime import datetime
from pathlib import Path
# Add parent directory to path for importing mtg_rules_engine
sys.path.insert(0, str(Path(__file__).parent.parent))
from mtg_rules_engine.updater import RulesUpdater
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('/home/user/wall-o/mtg_rules_engine/updater.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def check_for_updates(updater: RulesUpdater) -> dict:
"""Check for updates and return results."""
logger.info("Checking for rules updates...")
updates = updater.check_for_updates()
if updates['has_updates']:
logger.info(f"Updates available! Current: {updates['current_version']}, Latest: {updates['latest_version']}")
else:
logger.info(f"No updates available. Current version: {updates['current_version']}")
return updates
def apply_updates(updater: RulesUpdater) -> bool:
"""Apply updates if available."""
logger.info("Applying rules updates...")
try:
updater.apply_updates()
logger.info("Updates applied successfully")
return True
except Exception as e:
logger.error(f"Failed to apply updates: {e}")
return False
def scan_rules(updater: RulesUpdater) -> dict:
"""Scan and validate the rules repository."""
logger.info("Scanning rules repository...")
result = updater.scan_rules()
if result['status'] == 'success':
logger.info(f"Rules scan successful: {result['rules_count']} rules found")
else:
logger.error(f"Rules scan failed: {result['message']}")
if result['errors']:
logger.error(f"Errors: {result['errors']}")
return result
def run_weekly_check():
"""Run the weekly update check."""
logger.info("Running weekly update check...")
updater = RulesUpdater()
# Check for updates
updates = check_for_updates(updater)
if updates['has_updates']:
# Apply updates
success = apply_updates(updater)
if success:
logger.info("Weekly update check completed successfully")
else:
logger.error("Weekly update check failed")
sys.exit(1)
else:
logger.info("No updates needed")
# Scan rules to ensure they're valid
scan_result = scan_rules(updater)
if scan_result['status'] != 'success':
logger.error("Rules validation failed after update")
sys.exit(1)
def main():
"""Main entry point."""
import argparse
parser = argparse.ArgumentParser(description="MTG Rules Update Check")
parser.add_argument('--check', action='store_true', help='Check for updates only')
parser.add_argument('--apply', action='store_true', help='Apply updates')
parser.add_argument('--scan', action='store_true', help='Scan rules only')
parser.add_argument('--weekly', action='store_true', help='Run weekly check')
parser.add_argument('--initialize', action='store_true', help='Initialize the updater')
args = parser.parse_args()
updater = RulesUpdater()
if args.initialize:
logger.info("Initializing rules updater...")
try:
updater.initialize()
logger.info("Initialization complete")
except Exception as e:
logger.error(f"Initialization failed: {e}")
sys.exit(1)
elif args.check:
updates = check_for_updates(updater)
print(json.dumps(updates, indent=2))
elif args.apply:
success = apply_updates(updater)
if not success:
sys.exit(1)
elif args.scan:
result = scan_rules(updater)
print(json.dumps(result, indent=2))
elif args.weekly:
run_weekly_check()
else:
# Default: run weekly check
run_weekly_check()
if __name__ == "__main__":
main()
+441
View File
@@ -0,0 +1,441 @@
#!/usr/bin/env python3
"""
Magic: The Gathering Rules Updater
This module handles:
1. Downloading/updating the rules repository from GitHub
2. Scanning and validating the rules format
3. Updating the hardcoded keyword database
4. Scheduling weekly checks
Usage:
from mtg_rules_engine.updater import RulesUpdater
updater = RulesUpdater()
# Check for updates
updates = updater.check_for_updates()
if updates['has_updates']:
updater.apply_updates()
# Run on startup
updater.initialize()
"""
import os
import re
import json
import subprocess
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple, Any
from pathlib import Path
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class RulesUpdater:
"""
Handles downloading, validating, and updating the MTG rules database.
This updater:
- Clones/pulls the rules repository from GitHub
- Validates the rules format and completeness
- Updates the hardcoded keyword database
- Tracks the current rules version
"""
# GitHub repository URL
REPO_URL = "https://github.com/chaoticgoodcomputing/mtg-rules.git"
# Local rules directory
RULES_DIR = Path("/home/user/wall-o/mtg-rules")
# Engine directory
ENGINE_DIR = Path("/home/user/wall-o/mtg_rules_engine")
# Keywords file
KEYWORDS_FILE = ENGINE_DIR / "keywords.py"
# State file for tracking updates
STATE_FILE = ENGINE_DIR / "updater_state.json"
def __init__(self):
"""Initialize the rules updater."""
self._state = self._load_state()
self._last_check = None
self._last_update = None
def _load_state(self) -> Dict[str, Any]:
"""Load the updater state from disk."""
if self.STATE_FILE.exists():
with open(self.STATE_FILE, 'r') as f:
return json.load(f)
return {
"last_check": None,
"last_update": None,
"current_version": None,
"rules_count": 0,
"last_scan_status": None,
}
def _save_state(self):
"""Save the updater state to disk."""
self.STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(self.STATE_FILE, 'w') as f:
json.dump(self._state, f, indent=2)
def initialize(self):
"""
Initialize the updater: download rules and run initial scan.
This should be called on engine startup.
"""
logger.info("Initializing rules updater...")
# Step 1: Ensure rules directory exists
if not self.RULES_DIR.exists():
logger.info("Cloning rules repository...")
self._clone_repo()
else:
logger.info("Pulling latest rules...")
self._pull_repo()
# Step 2: Scan and validate rules
logger.info("Scanning rules...")
scan_result = self.scan_rules()
if scan_result['status'] == 'error':
logger.error(f"Rules scan failed: {scan_result['message']}")
raise RuntimeError(f"Rules scan failed: {scan_result['message']}")
# Step 3: Update keyword database if needed
if scan_result['needs_update']:
logger.info("Updating keyword database...")
self._update_keywords(scan_result)
# Step 4: Update state
self._state['last_check'] = datetime.now().isoformat()
self._state['last_update'] = datetime.now().isoformat()
self._state['current_version'] = scan_result['version']
self._state['rules_count'] = scan_result['rules_count']
self._state['last_scan_status'] = 'success'
self._save_state()
logger.info(f"Initialization complete. Version: {scan_result['version']}")
def check_for_updates(self) -> Dict[str, Any]:
"""
Check if there are updates available from the rules repository.
Returns:
Dictionary with update information:
- has_updates: bool
- current_version: str
- latest_version: str
- changes: list of change descriptions
"""
logger.info("Checking for rules updates...")
# Get current version
current_version = self._get_current_version()
# Get latest version from remote
latest_version = self._get_latest_version()
has_updates = current_version != latest_version
return {
'has_updates': has_updates,
'current_version': current_version,
'latest_version': latest_version,
'changes': [] if not has_updates else ['New rules version available'],
}
def apply_updates(self):
"""Apply any available updates to the rules repository."""
logger.info("Applying rules updates...")
# Pull latest changes
self._pull_repo()
# Scan and validate
scan_result = self.scan_rules()
if scan_result['status'] == 'error':
logger.error(f"Rules scan failed after update: {scan_result['message']}")
raise RuntimeError(f"Rules scan failed: {scan_result['message']}")
# Update keyword database
if scan_result['needs_update']:
self._update_keywords(scan_result)
# Update state
self._state['last_update'] = datetime.now().isoformat()
self._state['current_version'] = scan_result['version']
self._state['rules_count'] = scan_result['rules_count']
self._save_state()
logger.info(f"Updates applied. New version: {scan_result['version']}")
def scan_rules(self) -> Dict[str, Any]:
"""
Scan the rules repository and validate format.
Returns:
Dictionary with scan results:
- status: 'success' or 'error'
- message: description of result
- version: current rules version
- rules_count: number of rules found
- needs_update: whether keyword database needs updating
- errors: list of any errors found
"""
errors = []
rules_count = 0
version = None
# Check if rules directory exists
if not self.RULES_DIR.exists():
return {
'status': 'error',
'message': 'Rules directory not found',
'version': None,
'rules_count': 0,
'needs_update': False,
'errors': ['Rules directory not found'],
}
# Get version from VERSION file
version_file = self.RULES_DIR / "VERSION"
if version_file.exists():
with open(version_file, 'r') as f:
version = f.read().strip()
else:
errors.append("VERSION file not found")
# Scan all markdown files in rules directory
rules_dir = self.RULES_DIR / "rules"
if rules_dir.exists():
for md_file in rules_dir.rglob("*.md"):
rules_count += 1
# Validate file format
file_errors = self._validate_rule_file(md_file)
errors.extend(file_errors)
# Check for required files
required_files = ["INTRO.md", "TABLE_OF_CONTENTS.md", "GLOSSARY.md", "CREDITS.md"]
for req_file in required_files:
if not (rules_dir / req_file).exists():
errors.append(f"Required file missing: {req_file}")
# Check for rules subdirectories
rules_subdirs = rules_dir / "rules"
if rules_subdirs.exists():
for subdir in rules_subdirs.iterdir():
if subdir.is_dir():
# Check that subdirectory has markdown files
md_files = list(subdir.glob("*.md"))
if not md_files:
errors.append(f"Rules subdirectory has no markdown files: {subdir.name}")
needs_update = len(errors) > 0 or rules_count != self._state.get('rules_count', 0)
return {
'status': 'error' if errors else 'success',
'message': '; '.join(errors) if errors else 'All rules validated successfully',
'version': version,
'rules_count': rules_count,
'needs_update': needs_update,
'errors': errors,
}
def _validate_rule_file(self, file_path: Path) -> List[str]:
"""
Validate a single rule file's format.
Args:
file_path: Path to the markdown file
Returns:
List of validation errors (empty if valid)
"""
errors = []
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
return [f"Cannot read file {file_path}: {str(e)}"]
# Check for empty files
if not content.strip():
errors.append(f"Empty file: {file_path.relative_to(self.RULES_DIR)}")
return errors
# Check for rule number format in filename (e.g., "100-general.md")
if file_path.parent.name == "rules":
# This is a main rule section
filename = file_path.stem
if not re.match(r'^\d+', filename):
errors.append(f"Rule file doesn't start with number: {file_path.relative_to(self.RULES_DIR)}")
return errors
def _update_keywords(self, scan_result: Dict[str, Any]):
"""
Update the hardcoded keyword database based on scanned rules.
Args:
scan_result: Results from scan_rules()
"""
logger.info("Extracting keywords from rules...")
# This would extract keywords from the rules markdown files
# and update the keywords.py file
# For now, we'll just log that an update is needed
logger.info("Keyword database update would be performed here")
logger.info(f"Rules version: {scan_result['version']}")
logger.info(f"Rules count: {scan_result['rules_count']}")
def _clone_repo(self):
"""Clone the rules repository."""
try:
subprocess.run(
["git", "clone", self.REPO_URL, str(self.RULES_DIR)],
check=True,
capture_output=True,
text=True,
)
logger.info("Repository cloned successfully")
except subprocess.CalledProcessError as e:
logger.error(f"Failed to clone repository: {e.stderr}")
raise
def _pull_repo(self):
"""Pull latest changes from the repository."""
try:
subprocess.run(
["git", "-C", str(self.RULES_DIR), "pull"],
check=True,
capture_output=True,
text=True,
)
logger.info("Repository pulled successfully")
except subprocess.CalledProcessError as e:
logger.error(f"Failed to pull repository: {e.stderr}")
raise
def _get_current_version(self) -> str:
"""Get the current rules version."""
version_file = self.RULES_DIR / "VERSION"
if version_file.exists():
with open(version_file, 'r') as f:
return f.read().strip()
return "unknown"
def _get_latest_version(self) -> str:
"""Get the latest rules version from the remote repository."""
try:
result = subprocess.run(
["git", "-C", str(self.RULES_DIR), "ls-remote", "origin", "HEAD"],
check=True,
capture_output=True,
text=True,
)
# Parse the output to get the latest commit hash
lines = result.stdout.strip().split('\n')
if lines:
return lines[0].split()[0]
except subprocess.CalledProcessError:
pass
return self._get_current_version()
def schedule_weekly_check(self):
"""
Schedule a weekly check for rules updates.
This creates a cron job that runs every Monday at 9 AM.
"""
cron_expression = "0 9 * * 1" # Every Monday at 9 AM
# Create a script that runs the updater
script_path = self.ENGINE_DIR / "weekly_update.sh"
script_content = f"""#!/bin/bash
cd {self.ENGINE_DIR.parent}
python -m mtg_rules_engine.updater --weekly
"""
with open(script_path, 'w') as f:
f.write(script_content)
os.chmod(script_path, 0o755)
# Add to crontab
cron_job = f"{cron_expression} {script_path}\n"
existing_cron = subprocess.run(
["crontab", "-l"],
capture_output=True,
text=True,
)
if existing_cron.returncode == 0:
new_cron = existing_cron.stdout + cron_job
else:
new_cron = cron_job
subprocess.run(
["crontab", "-"],
input=new_cron,
text=True,
)
logger.info(f"Weekly update scheduled at {cron_expression}")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="MTG Rules Updater")
parser.add_argument("--check", action="store_true", help="Check for updates")
parser.add_argument("--apply", action="store_true", help="Apply updates")
parser.add_argument("--scan", action="store_true", help="Scan rules")
parser.add_argument("--weekly", action="store_true", help="Run weekly check")
args = parser.parse_args()
updater = RulesUpdater()
if args.check:
updates = updater.check_for_updates()
print(f"Has updates: {updates['has_updates']}")
print(f"Current version: {updates['current_version']}")
print(f"Latest version: {updates['latest_version']}")
elif args.apply:
updater.apply_updates()
elif args.scan:
result = updater.scan_rules()
print(f"Status: {result['status']}")
print(f"Message: {result['message']}")
print(f"Version: {result['version']}")
print(f"Rules count: {result['rules_count']}")
if result['errors']:
print(f"Errors: {result['errors']}")
elif args.weekly:
updates = updater.check_for_updates()
if updates['has_updates']:
updater.apply_updates()
else:
print("No updates available")
else:
# Default: initialize
updater.initialize()
+319
View File
@@ -0,0 +1,319 @@
"""
Magic: The Gathering Rules Engine - Keyword Validator
This module provides validation functionality for Magic keywords.
It checks if keywords are valid, finds all keywords in text, and
provides detailed analysis of keyword usage.
Usage:
from mtg_rules_engine.validator import KeywordValidator
# Check if a keyword is valid
validator = KeywordValidator()
if validator.is_valid_keyword("flying"):
print("flying is a valid keyword")
# Find all keywords in text
text = "Flying creatures can't be blocked except by flying creatures."
found = validator.find_keywords_in_text(text)
print(f"Found keywords: {found}")
# Validate card text
card_text = "Flying creature. Haste. Flying and trample."
errors = validator.validate_card_text(card_text)
print(f"Validation errors: {errors}")
"""
from typing import Dict, List, Optional, Set, Tuple, Any
from .keywords import (
ABILITY_WORDS,
KEYWORD_ACTIONS,
KEYWORD_ABILITIES,
KEYWORD_VARIANTS,
get_all_keywords,
get_keyword_info,
is_valid_keyword,
get_keyword_type,
)
class KeywordValidator:
"""
Validates and analyzes Magic keyword usage in rules text and card text.
This validator can:
- Check if a keyword is valid
- Find all keywords in a given text
- Validate card text for unrecognized keywords
- Analyze keyword patterns in rules text
"""
def __init__(self):
"""Initialize the validator with all keywords."""
self._all_keywords: Set[str] = get_all_keywords()
self._keyword_info_cache: Dict[str, Dict[str, Any]] = {}
def is_valid_keyword(self, keyword: str) -> bool:
"""
Check if a keyword is a valid Magic keyword.
Args:
keyword: The keyword to check
Returns:
True if the keyword is valid, False otherwise
"""
return keyword.lower() in self._all_keywords
def get_keyword_type(self, keyword: str) -> str:
"""
Get the type of a keyword (ability_word, keyword_action, or keyword_ability).
Args:
keyword: The keyword to check
Returns:
The type of the keyword, or "unknown" if not found
"""
return get_keyword_type(keyword.lower())
def get_keyword_info(self, keyword: str) -> Optional[Dict[str, Any]]:
"""
Get detailed information about a keyword.
Args:
keyword: The keyword to look up
Returns:
Dictionary with keyword information, or None if not found
"""
keyword = keyword.lower()
if keyword not in self._keyword_info_cache:
self._keyword_info_cache[keyword] = get_keyword_info(keyword)
return self._keyword_info_cache[keyword]
def find_keywords_in_text(self, text: str,
case_sensitive: bool = False) -> Dict[str, int]:
"""
Find all keywords present in the given text.
Args:
text: The text to search
case_sensitive: Whether the search should be case-sensitive
Returns:
Dictionary mapping keyword names to their counts in the text
"""
keywords_found: Dict[str, int] = {}
if case_sensitive:
words = text.split()
for word in words:
# Clean punctuation from words
clean_word = ''.join(c for c in word if c.isalnum() or c == "'")
if clean_word in self._all_keywords:
keywords_found[clean_word] = keywords_found.get(clean_word, 0) + 1
else:
words = text.lower().split()
for word in words:
# Clean punctuation from words
clean_word = ''.join(c for c in word if c.isalnum() or c == "'")
if clean_word in self._all_keywords:
keywords_found[clean_word] = keywords_found.get(clean_word, 0) + 1
return keywords_found
def find_keyword_occurrences(self, text: str,
keyword: str,
case_sensitive: bool = False) -> List[Tuple[int, int]]:
"""
Find all occurrences of a keyword in the given text.
Args:
text: The text to search
keyword: The keyword to find
case_sensitive: Whether the search should be case-sensitive
Returns:
List of (start_index, end_index) tuples for each occurrence
"""
occurrences = []
search_text = text if case_sensitive else text.lower()
search_keyword = keyword if case_sensitive else keyword.lower()
start = 0
while True:
start = search_text.find(search_keyword, start)
if start == -1:
break
end = start + len(search_keyword)
occurrences.append((start, end))
start = end
return occurrences
def validate_card_text(self, card_text: str,
ignore_unrecognized: bool = False) -> List[Dict[str, str]]:
"""
Validate a card's rules text for keyword usage.
Args:
card_text: The card's rules text
ignore_unrecognized: If True, don't report unrecognized keywords
Returns:
List of validation errors (empty if no errors)
"""
errors: List[Dict[str, str]] = []
keywords_found = self.find_keywords_in_text(card_text)
for keyword, count in keywords_found.items():
info = self.get_keyword_info(keyword)
if info is None:
if not ignore_unrecognized:
errors.append({
"keyword": keyword,
"message": f"Unrecognized keyword: '{keyword}' (found {count} time(s))",
"type": "unrecognized_keyword"
})
else:
# Check for common issues
info_type = info.get("category", "unknown")
if info_type == "keyword_action":
# Check if keyword is used as a verb in the text
pass # Actions are typically used as verbs
elif info_type == "keyword_ability":
pass # Abilities are typically used as adjectives or nouns
return errors
def analyze_rules_text(self, text: str) -> Dict[str, Any]:
"""
Perform a comprehensive analysis of keywords in rules text.
Args:
text: The rules text to analyze
Returns:
Dictionary with analysis results
"""
keywords_found = self.find_keywords_in_text(text)
analysis = {
"total_keywords": len(keywords_found),
"keywords_found": keywords_found,
"ability_words_found": {},
"keyword_actions_found": {},
"keyword_abilities_found": {},
}
for keyword, count in keywords_found.items():
info = self.get_keyword_info(keyword)
if info is None:
continue
category = info.get("category", "unknown")
if category == "ability_word":
analysis["ability_words_found"][keyword] = count
elif category == "keyword_action":
analysis["keyword_actions_found"][keyword] = count
elif category == "keyword_ability":
analysis["keyword_abilities_found"][keyword] = count
return analysis
def check_for_misspelled_keywords(self, text: str) -> List[Dict[str, Any]]:
"""
Check for potentially misspelled keywords in text.
Args:
text: The text to check
Returns:
List of potential misspellings with suggestions
"""
words = text.lower().split()
misspellings = []
for word in words:
clean_word = ''.join(c for c in word if c.isalnum() or c == "'")
if len(clean_word) < 3:
continue
if clean_word not in self._all_keywords:
# Try to find similar keywords
similar = self._find_similar_keywords(clean_word)
if similar:
misspellings.append({
"word": clean_word,
"suggestions": similar[:3], # Top 3 suggestions
"message": f"Did you mean one of: {', '.join(similar[:3])}"
})
return misspellings
def _find_similar_keywords(self, word: str) -> List[str]:
"""Find keywords that are similar to the given word."""
similar = []
for keyword in self._all_keywords:
# Use simple Levenshtein distance
distance = self._levenshtein_distance(word, keyword)
if distance <= 3 and len(keyword) <= len(word) + 2:
similar.append((keyword, distance))
# Sort by distance and return unique keywords
similar.sort(key=lambda x: x[1])
return [k for k, _ in similar[:10]]
@staticmethod
def _levenshtein_distance(s1: str, s2: str) -> int:
"""Calculate the Levenshtein distance between two strings."""
if len(s1) < len(s2):
return KeywordValidator._levenshtein_distance(s2, s1)
if len(s2) == 0:
return len(s1)
previous_row = range(len(s2) + 1)
for i, c1 in enumerate(s1):
current_row = [i + 1]
for j, c2 in enumerate(s2):
insertions = previous_row[j + 1] + 1
deletions = current_row[j] + 1
substitutions = previous_row[j] + (c1 != c2)
current_row.append(min(insertions, deletions, substitutions))
previous_row = current_row
return previous_row[-1]
def get_all_keywords_by_type(self) -> Dict[str, Set[str]]:
"""
Get all keywords grouped by type.
Returns:
Dictionary mapping keyword types to sets of keywords
"""
result = {
"ability_words": set(ABILITY_WORDS.keys()),
"keyword_actions": set(KEYWORD_ACTIONS.keys()),
"keyword_abilities": set(KEYWORD_ABILITIES.keys()),
}
return result
def export_keywords(self) -> Dict[str, Any]:
"""
Export all keywords as a structured dictionary.
Returns:
Dictionary with all keyword data
"""
return {
"total_keywords": len(self._all_keywords),
"ability_words": list(ABILITY_WORDS.keys()),
"keyword_actions": list(KEYWORD_ACTIONS.keys()),
"keyword_abilities": list(KEYWORD_ABILITIES.keys()),
"keyword_variants": {
k: v.get("variants", [])
for k, v in KEYWORD_ABILITIES.items() if v.get("variants")
},
}