672 lines
21 KiB
Python
672 lines
21 KiB
Python
"""
|
|
MTG Card Profile Extractor
|
|
|
|
Extracts structured profiles from MTGJSON card data.
|
|
Identifies mechanics, archetypes, mana costs, targets, and other game-relevant attributes.
|
|
"""
|
|
import re
|
|
from typing import List, Dict, Optional, Set
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@dataclass
|
|
class CardProfile:
|
|
"""
|
|
Structured profile of a card for interaction analysis.
|
|
|
|
Contains all relevant game attributes extracted from MTGJSON data.
|
|
"""
|
|
# Basic info
|
|
id: int
|
|
name: str
|
|
mana_cost: Optional[str]
|
|
type_line: Optional[str]
|
|
oracle_text: Optional[str]
|
|
subtypes: Optional[str]
|
|
supertypes: Optional[str]
|
|
set_code: Optional[str]
|
|
|
|
# Extracted attributes
|
|
colors: List[str] = None # ['W', 'U', 'B', 'R', 'G']
|
|
color_identity: List[str] = None
|
|
mechanics: List[str] = None
|
|
archetypes: List[str] = None
|
|
targets: List[str] = None # ['creature', 'artifact', 'player', etc.]
|
|
triggers: List[str] = None
|
|
effects: List[str] = None
|
|
themes: List[str] = None # ['storm', 'tokens', 'draw', etc.]
|
|
|
|
def __post_init__(self):
|
|
"""Initialize lists if None."""
|
|
if self.colors is None:
|
|
self.colors = []
|
|
if self.color_identity is None:
|
|
self.color_identity = []
|
|
if self.mechanics is None:
|
|
self.mechanics = []
|
|
if self.archetypes is None:
|
|
self.archetypes = []
|
|
if self.targets is None:
|
|
self.targets = []
|
|
if self.triggers is None:
|
|
self.triggers = []
|
|
if self.effects is None:
|
|
self.effects = []
|
|
if self.themes is None:
|
|
self.themes = []
|
|
|
|
def to_dict(self) -> Dict:
|
|
"""Convert profile to dictionary."""
|
|
return {
|
|
'id': self.id,
|
|
'name': self.name,
|
|
'mana_cost': self.mana_cost,
|
|
'type_line': self.type_line,
|
|
'oracle_text': self.oracle_text,
|
|
'subtypes': self.subtypes,
|
|
'supertypes': self.supertypes,
|
|
'set_code': self.set_code,
|
|
'colors': self.colors,
|
|
'color_identity': self.color_identity,
|
|
'mechanics': self.mechanics,
|
|
'archetypes': self.archetypes,
|
|
'targets': self.targets,
|
|
'triggers': self.triggers,
|
|
'effects': self.effects,
|
|
'themes': self.themes,
|
|
}
|
|
|
|
|
|
class CardProfileExtractor:
|
|
"""
|
|
Extracts card profiles from MTGJSON data.
|
|
|
|
Uses regex patterns and curated dictionaries to identify:
|
|
- Mana costs and color identity
|
|
- Game mechanics (flying, first strike, etc.)
|
|
- Archetypes (goblin, elf, vampire, etc.)
|
|
- Targets (creature, artifact, player, etc.)
|
|
- Triggers (enters battlefield, dies, attacks, etc.)
|
|
- Effects (gain flying, draw card, etc.)
|
|
- Themes (storm, tokens, mill, etc.)
|
|
"""
|
|
|
|
# Color symbols in mana costs
|
|
COLOR_SYMBOLS = {
|
|
'{W}': 'W',
|
|
'{U}': 'U',
|
|
'{B}': 'B',
|
|
'{R}': 'R',
|
|
'{G}': 'G',
|
|
}
|
|
|
|
# Known mechanics and their patterns
|
|
MECHANICS = {
|
|
'flying': r'Flying',
|
|
'first_strike': r'First strike',
|
|
'double_strike': r'Double strike',
|
|
'deathtouch': r'Death touch',
|
|
'lifelink': r'Lifelink',
|
|
'haste': r'Haste',
|
|
'trample': r'Trample',
|
|
'menace': r'Menace',
|
|
'vigilance': r'Vegilance',
|
|
'reach': r'Reach',
|
|
'indestructible': r'Indestructible',
|
|
'hexproof': r'Hexproof',
|
|
'shroud': r'Shroud',
|
|
'defender': r'Defender',
|
|
'landfall': r'Landfall',
|
|
'delve': r'Delve',
|
|
'suspend': r'Suspend',
|
|
'convoke': r'Convoke',
|
|
'rampage': r'Rampage',
|
|
'toxic': r'Toxic',
|
|
'crew': r'Crew',
|
|
'equip': r'Equip',
|
|
'annihilator': r'Annihilator',
|
|
'spectacle': r'Spectacle',
|
|
'prowess': r'Prowess',
|
|
'aftermath': r'Aftermath',
|
|
'adapt': r'Adapt',
|
|
'amplify': r'Amplify',
|
|
'awaken': r'Awaken',
|
|
'kicker': r'Kicker',
|
|
'morph': r'Morph',
|
|
'evolve': r'Evolve',
|
|
'exalted': r'Exalted',
|
|
'storm': r'Storm',
|
|
'madness': r'Madness',
|
|
'manifest': r'Manifest',
|
|
'modular': r'Modular',
|
|
'mutate': r'Mutate',
|
|
'transform': r'Transform',
|
|
'unearth': r'Unearth',
|
|
'persist': r'Persist',
|
|
'rebound': r'Rebound',
|
|
'replicate': r'Replicate',
|
|
'soulshift': r'Soulshift',
|
|
'dredge': r'Dredge',
|
|
'devour': r'Devour',
|
|
'banding': r'Band with',
|
|
'bestow': r'Bestow',
|
|
'channel': r'Channel',
|
|
'clash': r'Clash',
|
|
'curse': r'Curse',
|
|
'dwell': r'Dwell',
|
|
'evoke': r'Evoke',
|
|
'exploit': r'Exploit',
|
|
'extort': r'Extort',
|
|
'flash': r'Flash',
|
|
'foretell': r'Foretell',
|
|
'frenzy': r'Frenzy',
|
|
'grudge': r'Grudge',
|
|
'heroic': r'Heroic',
|
|
'hideaway': r'Hideaway',
|
|
'horrify': r'Horrify',
|
|
'impetus': r'Impetus',
|
|
'infect': r'Infect',
|
|
'journey': r'Journey',
|
|
'kicker': r'Kicker',
|
|
'landfall': r'Landfall',
|
|
'meld': r'Meld',
|
|
'miracle': r'Miracle',
|
|
'monstrosity': r'Monstrosity',
|
|
'morph': r'Morph',
|
|
'mutate': r'Mutate',
|
|
'ninja': r'Ninja',
|
|
'pact': r'Pact',
|
|
'persist': r'Persist',
|
|
'provoke': r'Provoke',
|
|
'quest': r'Quest',
|
|
'raid': r'Raid',
|
|
'rebound': r'Rebound',
|
|
'replicate': r'Replicate',
|
|
'revolt': r'Revolt',
|
|
'shroud': r'Shroud',
|
|
'skulk': r'Skulk',
|
|
'snow': r'Snow',
|
|
'splice': r'Splice',
|
|
'staunch': r'Staunch',
|
|
'storm': r'Storm',
|
|
'suspend': r'Suspend',
|
|
'surge': r'Surge',
|
|
'swarm': r'Swarm',
|
|
'thorn': r'Thorn',
|
|
'toxic': r'Toxic',
|
|
'transfigure': r'Transfigure',
|
|
'transform': r'Transform',
|
|
'unearth': r'Unearth',
|
|
'unleash': r'Unleash',
|
|
'vampiric': r'Vampiric',
|
|
'ward': r'Ward',
|
|
'willow': r'Willow',
|
|
'winter': r'Winter',
|
|
'wither': r'Wither',
|
|
'wurm': r'Wurm',
|
|
}
|
|
|
|
# Known archetypes and their patterns
|
|
ARCHETYPES = {
|
|
'goblin': r'Goblin',
|
|
'elf': r'Elf',
|
|
'vampire': r'Veampire',
|
|
'angel': r'Angel',
|
|
'dragon': r'Dragon',
|
|
'human': r'Human',
|
|
'zombie': r'Zombie',
|
|
'soldier': r'Soldier',
|
|
'knight': r'Knight',
|
|
'wizard': r'Wizard',
|
|
'spirit': r'Spirit',
|
|
'demon': r'Demon',
|
|
'snake': r'Snake',
|
|
'cat': r'Cat',
|
|
'wolf': r'Wolf',
|
|
'bear': r'Bear',
|
|
'bird': r'Bird',
|
|
'insect': r'Insect',
|
|
'horror': r'Horror',
|
|
'goat': r'Goat',
|
|
'ox': r'Ox',
|
|
'elephant': r'Elephant',
|
|
'whale': r'Whale',
|
|
'shark': r'Shark',
|
|
'fish': r'Fish',
|
|
'serpent': r'Serpent',
|
|
'lizard': r'Lizard',
|
|
'scorpion': r'Scorpion',
|
|
'spider': r'Spider',
|
|
'rat': r'Rat',
|
|
'drake': r'Drake',
|
|
'wyvern': r'Wyvern',
|
|
'phoenix': r'Phoenix',
|
|
'lynx': r'Lynx',
|
|
'jaguar': r'Jaguar',
|
|
'hydra': r'Hydra',
|
|
'leviathan': r'Leviathan',
|
|
'kraken': r'Kraken',
|
|
'cyclops': r'Cyclops',
|
|
'golem': r'Golem',
|
|
'homunculus': r'Homunculus',
|
|
'clay': r'Clay',
|
|
'construct': r'Construct',
|
|
'myr': r'Myr',
|
|
'pirate': r'Pirate',
|
|
'pegasus': r'Pegasus',
|
|
'unicorn': r'Unicorn',
|
|
'centaur': r'Centaur',
|
|
'merfolk': r'Merfolk',
|
|
'mermaid': r'Mermaid',
|
|
'naga': r'Naga',
|
|
'satyr': r'Satyr',
|
|
'dryad': r'Dryad',
|
|
'treant': r'Treant',
|
|
'elemental': r'Elemental',
|
|
'fiend': r'Fiend',
|
|
'imp': r'Imp',
|
|
'faerie': r'Faerie',
|
|
'minion': r'Minion',
|
|
'abomination': r'Abomination',
|
|
'beast': r'Beast',
|
|
'demigod': r'Demigod',
|
|
'god': r'God',
|
|
'avatar': r'Avatar',
|
|
'guardian': r'Guardian',
|
|
'warrior': r'Warrior',
|
|
'rogue': r'Rogue',
|
|
'artificer': r'Artificer',
|
|
'bard': r'Bard',
|
|
'monk': r'Monk',
|
|
'ninja': r'Ninja',
|
|
'samurai': r'Samurai',
|
|
'assassin': r'Assassin',
|
|
'thief': r'Thief',
|
|
'acrobat': r'Acrobat',
|
|
'explorer': r'Explorer',
|
|
'myth': r'Myth',
|
|
'illusion': r'Illusion',
|
|
'mirror': r'Mirror',
|
|
'phantom': r'Phantom',
|
|
'shapeshifter': r'Shapeshifter',
|
|
'shaman': r'Shaman',
|
|
'skeleton': r'Skeleton',
|
|
'slime': r'Slime',
|
|
'squirrel': r'Squirrel',
|
|
'troll': r'Troll',
|
|
'tyrannosaur': r'Tyrannosaur',
|
|
'wraith': r'Wraith',
|
|
'wurm': r'Wurm',
|
|
}
|
|
|
|
# Target types and their patterns
|
|
TARGET_TYPES = {
|
|
'creature': r'creature',
|
|
'artifact': r'artifact',
|
|
'enchantment': r'enchantment',
|
|
'instant': r'instant',
|
|
'sorcery': r'sorcery',
|
|
'planeswalker': r'planeswalker',
|
|
'land': r'land',
|
|
'player': r'player',
|
|
'spell': r'spell',
|
|
'permanent': r'permanent',
|
|
'creature card': r'creature [Cc]ard',
|
|
}
|
|
|
|
# Trigger conditions and their patterns
|
|
TRIGGERS = {
|
|
'enters_battlefield': r'when [~|this] enters the battlefield',
|
|
'leaves_battlefield': r'when [~|this] leaves the battlefield',
|
|
'attacks': r'whenever [~|this] attacks',
|
|
'blocks': r'whenever [~|this] blocks',
|
|
'dies': r'when [~|this] dies',
|
|
'damage': r'deals [0-9]+ damage',
|
|
'draws_card': r'draw a card|draw two cards',
|
|
'gains_life': r'gain [0-9]+ life',
|
|
'creates_token': r'create a token',
|
|
'taps': r'tap: add',
|
|
'untaps': r'untap: add',
|
|
'destroys': r'destroy target',
|
|
'exiles': r'exile target',
|
|
'counters_spell': r'counter target spell',
|
|
}
|
|
|
|
# Game effects and their patterns
|
|
EFFECTS = {
|
|
'gain_flying': r'gain flying',
|
|
'gain_first_strike': r'gain first strike',
|
|
'gain_double_strike': r'gain double strike',
|
|
'gain_deathtouch': r'gain deathtouch',
|
|
'gain_lifelink': r'gain lifelink',
|
|
'gain_haste': r'gain haste',
|
|
'gain_trample': r'gain trample',
|
|
'gain_vigilance': r'gain vigilance',
|
|
'gain_indestructible': r'gain indestructible',
|
|
'gain_hexproof': r'gain hexproof',
|
|
'until_end_of_turn': r'until end of turn',
|
|
'until_next_turn': r'until your next turn',
|
|
'deal_damage': r'deal [0-9]+ damage',
|
|
'gain_life': r'gain [0-9]+ life',
|
|
'draw_card': r'draw [0-9]+ card',
|
|
'create_token': r'create [0-9]+ token',
|
|
'destroy': r'destroy target',
|
|
'exile': r'exile target',
|
|
'counter_spell': r'counter target spell',
|
|
}
|
|
|
|
# Theme keywords and their patterns
|
|
THEMES = {
|
|
'storm': r'Storm',
|
|
'tokens': r'create a token',
|
|
'draw': r'draw a card',
|
|
'life_gain': r'gain life',
|
|
'board_wipe': r'destroy all',
|
|
'reanimate': r'put from grave',
|
|
'countermagic': r'counter target spell',
|
|
'card_advantage': r'draw',
|
|
'mana_acceleration': r'tap: add',
|
|
'combat_tricks': r'gain [A-Za-z]+ until end of turn',
|
|
'etb_effects': r'enters the battlefield',
|
|
'ltb_effects': r'leaves the battlefield',
|
|
'mill': r'put on bottom of library',
|
|
'draw_go': r'draw a card',
|
|
'aggro': r'deal [0-9]+ damage',
|
|
'control': r'counter target spell',
|
|
'midrange': r'creature',
|
|
}
|
|
|
|
def __init__(self):
|
|
"""Initialize the profile extractor."""
|
|
pass
|
|
|
|
def extract_colors(self, mana_cost: Optional[str]) -> List[str]:
|
|
"""
|
|
Extract colors from mana cost.
|
|
|
|
Args:
|
|
mana_cost: Mana cost string (e.g., '{1}{R}')
|
|
|
|
Returns:
|
|
List of color symbols (e.g., ['R'])
|
|
"""
|
|
if not mana_cost:
|
|
return []
|
|
|
|
colors = []
|
|
for symbol, color in self.COLOR_SYMBOLS.items():
|
|
if symbol in mana_cost:
|
|
if color not in colors:
|
|
colors.append(color)
|
|
|
|
return colors
|
|
|
|
def extract_mechanics(self, card_type_line: Optional[str], card_oracle: Optional[str]) -> List[str]:
|
|
"""
|
|
Extract game mechanics from card text.
|
|
|
|
Args:
|
|
card_type_line: Card type line (e.g., 'Creature - Goblin Warrior')
|
|
card_oracle: Card oracle text
|
|
|
|
Returns:
|
|
List of mechanic names (e.g., ['haste', 'trample'])
|
|
"""
|
|
mechanics = []
|
|
|
|
# Combine type line and oracle text for checking
|
|
text_to_check = f"{card_type_line or ''} {card_oracle or ''}".upper()
|
|
|
|
for mechanic, pattern in self.MECHANICS.items():
|
|
if re.search(pattern, text_to_check, re.IGNORECASE):
|
|
if mechanic not in mechanics:
|
|
mechanics.append(mechanic)
|
|
|
|
return mechanics
|
|
|
|
def extract_archetypes(self, card_subtypes: Optional[str]) -> List[str]:
|
|
"""
|
|
Extract archetypes from card subtypes.
|
|
|
|
Args:
|
|
card_subtypes: Card subtypes (e.g., 'Goblin, Warrior')
|
|
|
|
Returns:
|
|
List of archetype names (e.g., ['goblin'])
|
|
"""
|
|
if not card_subtypes:
|
|
return []
|
|
|
|
archetypes = []
|
|
|
|
for archetype, pattern in self.ARCHETYPES.items():
|
|
if re.search(pattern, card_subtypes, re.IGNORECASE):
|
|
if archetype not in archetypes:
|
|
archetypes.append(archetype)
|
|
|
|
return archetypes
|
|
|
|
def extract_targets(self, card_oracle: Optional[str]) -> List[str]:
|
|
"""
|
|
Extract target types from oracle text.
|
|
|
|
Args:
|
|
card_oracle: Card oracle text
|
|
|
|
Returns:
|
|
List of target types (e.g., ['creature', 'player'])
|
|
"""
|
|
if not card_oracle:
|
|
return []
|
|
|
|
targets = []
|
|
|
|
for target, pattern in self.TARGET_TYPES.items():
|
|
if re.search(pattern, card_oracle, re.IGNORECASE):
|
|
if target not in targets:
|
|
targets.append(target)
|
|
|
|
return targets
|
|
|
|
def extract_triggers(self, card_oracle: Optional[str]) -> List[str]:
|
|
"""
|
|
Extract trigger conditions from oracle text.
|
|
|
|
Args:
|
|
card_oracle: Card oracle text
|
|
|
|
Returns:
|
|
List of trigger names (e.g., ['enters_battlefield', 'dies'])
|
|
"""
|
|
if not card_oracle:
|
|
return []
|
|
|
|
triggers = []
|
|
|
|
for trigger, pattern in self.TRIGGERS.items():
|
|
if re.search(pattern, card_oracle, re.IGNORECASE):
|
|
if trigger not in triggers:
|
|
triggers.append(trigger)
|
|
|
|
return triggers
|
|
|
|
def extract_effects(self, card_oracle: Optional[str]) -> List[str]:
|
|
"""
|
|
Extract game effects from oracle text.
|
|
|
|
Args:
|
|
card_oracle: Card oracle text
|
|
|
|
Returns:
|
|
List of effect names (e.g., ['gain_flying', 'draw_card'])
|
|
"""
|
|
if not card_oracle:
|
|
return []
|
|
|
|
effects = []
|
|
|
|
for effect, pattern in self.EFFECTS.items():
|
|
if re.search(pattern, card_oracle, re.IGNORECASE):
|
|
if effect not in effects:
|
|
effects.append(effect)
|
|
|
|
return effects
|
|
|
|
def extract_themes(self, card_mechanics: List[str], card_triggers: List[str],
|
|
card_effects: List[str], card_targets: List[str]) -> List[str]:
|
|
"""
|
|
Extract card themes based on characteristics.
|
|
|
|
Args:
|
|
card_mechanics: List of mechanics
|
|
card_triggers: List of triggers
|
|
card_effects: List of effects
|
|
card_targets: List of targets
|
|
|
|
Returns:
|
|
List of theme names (e.g., ['storm', 'tokens'])
|
|
"""
|
|
themes = []
|
|
|
|
# Storm theme
|
|
if 'storm' in card_mechanics or 'storm' in card_targets:
|
|
themes.append('storm')
|
|
|
|
# Token theme
|
|
if any(e in card_effects for e in ['create_token', 'draw_card']):
|
|
themes.append('tokens')
|
|
|
|
# Mill theme
|
|
if any(t in card_triggers for t in ['draws_card']):
|
|
themes.append('mill')
|
|
|
|
# Life gain theme
|
|
if any(e in card_effects for e in ['gain_life', 'draw_card']):
|
|
themes.append('life_gain')
|
|
|
|
# Board wipe theme
|
|
if any(t in card_triggers for t in ['dies']):
|
|
themes.append('board_wipe')
|
|
|
|
# Reanimate theme
|
|
if any(t in card_triggers for t in ['leaves_battlefield']):
|
|
themes.append('reanimate')
|
|
|
|
# Countermagic theme
|
|
if any(e in card_effects for e in ['counter_spell']):
|
|
themes.append('countermagic')
|
|
|
|
# Card advantage theme
|
|
if any(t in card_triggers for t in ['draws_card']):
|
|
themes.append('card_advantage')
|
|
|
|
# Mana acceleration theme
|
|
if any(t in card_triggers for t in ['taps']):
|
|
themes.append('mana_acceleration')
|
|
|
|
# Combat tricks theme
|
|
if any(e in card_effects for e in ['gain_flying', 'gain_first_strike',
|
|
'gain_double_strike', 'gain_deathtouch',
|
|
'gain_lifelink', 'gain_vigilance']):
|
|
themes.append('combat_tricks')
|
|
|
|
# ETB effects theme
|
|
if any(t in card_triggers for t in ['enters_battlefield']):
|
|
themes.append('etb_effects')
|
|
|
|
# LTB effects theme
|
|
if any(t in card_triggers for t in ['leaves_battlefield']):
|
|
themes.append('ltb_effects')
|
|
|
|
# Aggro theme
|
|
if any(e in card_effects for e in ['deal_damage']):
|
|
themes.append('aggro')
|
|
|
|
# Control theme
|
|
if any(e in card_effects for e in ['counter_spell']):
|
|
themes.append('control')
|
|
|
|
# Midrange theme
|
|
if any(t in card_targets for t in ['creature']):
|
|
themes.append('midrange')
|
|
|
|
return themes
|
|
|
|
def extract_profile(self, card_data: Dict) -> CardProfile:
|
|
"""
|
|
Extract a complete card profile from MTGJSON data.
|
|
|
|
Args:
|
|
card_data: MTGJSON card dictionary
|
|
|
|
Returns:
|
|
CardProfile object with all extracted attributes
|
|
"""
|
|
# Parse subtypes
|
|
subtypes = None
|
|
if card_data.get('subtypes'):
|
|
subtypes = ', '.join(card_data['subtypes'])
|
|
|
|
# Parse supertypes
|
|
supertypes = None
|
|
if card_data.get('supertypes'):
|
|
supertypes = ', '.join(card_data['supertypes'])
|
|
|
|
# Extract colors from mana cost
|
|
colors = self.extract_colors(card_data.get('manaCost'))
|
|
|
|
# Extract mechanics
|
|
mechanics = self.extract_mechanics(
|
|
card_data.get('typeLine'),
|
|
card_data.get('oracleText')
|
|
)
|
|
|
|
# Extract archetypes
|
|
archetypes = self.extract_archetypes(subtypes)
|
|
|
|
# Extract targets
|
|
targets = self.extract_targets(card_data.get('oracleText'))
|
|
|
|
# Extract triggers
|
|
triggers = self.extract_triggers(card_data.get('oracleText'))
|
|
|
|
# Extract effects
|
|
effects = self.extract_effects(card_data.get('oracleText'))
|
|
|
|
# Extract themes
|
|
themes = self.extract_themes(mechanics, triggers, effects, targets)
|
|
|
|
# Create profile
|
|
profile = CardProfile(
|
|
id=card_data.get('id', 0),
|
|
name=card_data.get('name', ''),
|
|
mana_cost=card_data.get('manaCost'),
|
|
type_line=card_data.get('typeLine'),
|
|
oracle_text=card_data.get('oracleText'),
|
|
subtypes=subtypes,
|
|
supertypes=supertypes,
|
|
set_code=card_data.get('set', {}).get('code') if card_data.get('set') else None,
|
|
colors=colors,
|
|
color_identity=card_data.get('colorIdentity'),
|
|
mechanics=mechanics,
|
|
archetypes=archetypes,
|
|
targets=targets,
|
|
triggers=triggers,
|
|
effects=effects,
|
|
themes=themes,
|
|
)
|
|
|
|
return profile
|
|
|
|
def extract_profiles_batch(self, cards: List[Dict]) -> List[CardProfile]:
|
|
"""
|
|
Extract profiles for a batch of cards.
|
|
|
|
Args:
|
|
cards: List of MTGJSON card dictionaries
|
|
|
|
Returns:
|
|
List of CardProfile objects
|
|
"""
|
|
return [self.extract_profile(card) for card in cards]
|