Files
mtgonline/backend/scripts/card_profile_extractor.py
T

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]