320 lines
11 KiB
Python
320 lines
11 KiB
Python
"""
|
|
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")
|
|
},
|
|
}
|