Files
mtgonline/backend/app/services/deck_parser.py
T

207 lines
7.3 KiB
Python

"""Deck list parsing and serialization utilities."""
import re
from typing import List, Dict, Tuple, Optional
from dataclasses import dataclass
@dataclass
class CardInfo:
"""Card information extracted from deck text."""
count: int
name: str
set_code: Optional[str] = None
collector_number: Optional[str] = None
def __str__(self) -> str:
if self.set_code and self.collector_number:
return f"{self.count} {self.name} ({self.set_code}-{self.collector_number})"
return f"{self.count} {self.name}"
class DeckParser:
"""Parse MTG Online deck list formats."""
# Regex patterns for deck parsing
CARD_LINE_RE = re.compile(r"^\s*[\w\[\(\{].*$")
EMPTY_LINE_RE = re.compile(r"^\s*$")
COMMENT_RE = re.compile(r"([\w\[\(\{].*$)")
SB_MARK_RE = re.compile(r"^\s*sb:\s*(.+)", re.IGNORECASE)
SB_COMMENT_RE = re.compile(r"^sideboard\s.*$", re.IGNORECASE)
DECK_COMMENT_RE = re.compile(r"^((main)?deck(list)?|mainboard)\b", re.IGNORECASE)
MULTIPLIER_RE = re.compile(r"^([xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))")
HYPHEN_FORMAT_RE = re.compile(r"\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*)")
REGULAR_FORMAT_RE = re.compile(r"\((\w{3,})\)\s+(\d+[^\w\s]*)")
def parse_plain_text(self, text: str) -> List[CardInfo]:
"""Parse plain text deck format."""
lines = text.strip().split('\n')
max_line = len(lines)
# Find deck start (first card line)
deck_start = -1
for i, line in enumerate(lines):
if self.CARD_LINE_RE.match(line):
deck_start = i
break
if deck_start == -1:
return []
# Find sideboard start
sBStart = -1
for i in range(deck_start, max_line):
if self.SB_MARK_RE.match(lines[i]):
sBStart = i
break
elif self.SB_COMMENT_RE.match(lines[i]):
sBStart = i
break
if sBStart == -1:
# Look for empty line after deck
for i in range(deck_start + 1, max_line):
if self.EMPTY_LINE_RE.match(lines[i]):
# Check if there are cards after the empty line
for j in range(i + 1, max_line):
if self.CARD_LINE_RE.match(lines[j]):
sBStart = i
break
if sBStart != -1:
break
if sBStart == -1:
sBStart = max_line
cards = []
index = 0
# Skip comments
while index < deck_start:
if not self.EMPTY_LINE_RE.match(lines[index]):
index += 1
else:
break
# Parse cards
for i in range(index, sBStart):
line = lines[i].strip()
if not line or self.EMPTY_LINE_RE.match(line):
continue
# Check for sideboard marker
if self.SB_MARK_RE.match(line):
match = self.SB_MARK_RE.match(line)
card_name = match.group(1).strip() if match else line
sideboard = True
else:
card_name = line
sideboard = False
# Extract set code and collector number
set_code = None
collector_number = None
match = self.HYPHEN_FORMAT_RE.search(card_name)
if match:
set_code = match.group(2).upper()
collector_number = match.group(3)
card_name = card_name[:match.start()].strip()
else:
match = self.REGULAR_FORMAT_RE.search(card_name)
if match:
set_code = match.group(1).upper()
collector_number = match.group(2)
card_name = card_name[:match.start()].strip()
# Extract count
count = 1
match = self.MULTIPLIER_RE.match(card_name)
if match:
count = int(match.group(2))
card_name = match.group(3)
# Normalize card name
card_name = card_name.strip()
if card_name:
cards.append(CardInfo(
count=count,
name=card_name,
set_code=set_code,
collector_number=collector_number,
))
return cards
def format_plain_text(self, cards: List[CardInfo], prefix_sideboard: bool = True) -> str:
"""Format cards as plain text deck list."""
lines = []
for card in cards:
prefix = "SB: " if prefix_sideboard and card.name.startswith("[SB]") else ""
name = card.name
if card.name.startswith("[SB]"):
name = card.name[4:] # Remove [SB] prefix
if card.set_code and card.collector_number:
lines.append(f"{prefix}{card.count} {name} ({card.set_code}-{card.collector_number})")
else:
lines.append(f"{prefix}{card.count} {name}")
return "\n".join(lines)
def to_native_xml(self, cards: List[CardInfo]) -> str:
"""Convert cards to MTG Online native XML format."""
xml_lines = ['<mtgonline_deck version="1">']
# Group cards by zone (main/sideboard)
main_cards = [c for c in cards if not c.name.startswith("[SB]")]
side_cards = [c for c in cards if c.name.startswith("[SB]")]
if main_cards:
xml_lines.append(' <zone name="main">')
for card in main_cards:
xml_lines.append(f' <card name="{card.name}" count="{card.count}" />')
xml_lines.append(' </zone>')
if side_cards:
xml_lines.append(' <zone name="sideboard">')
for card in side_cards:
clean_name = card.name[4:] # Remove [SB] prefix
xml_lines.append(f' <card name="{clean_name}" count="{card.count}" />')
xml_lines.append(' </zone>')
xml_lines.append('</mtgonline_deck>')
return "\n".join(xml_lines)
def from_native_xml(self, xml: str) -> List[CardInfo]:
"""Parse MTG Online native XML format."""
cards = []
# Simple XML parsing (in production, use proper XML parser)
card_pattern = re.compile(r'<card\s+name="([^"]+)"\s+count="(\d+)"\s*/>')
for match in card_pattern.finditer(xml):
name = match.group(1)
count = int(match.group(2))
cards.append(CardInfo(count=count, name=name))
return cards
# Convenience functions
def parse_deck(text: str) -> List[CardInfo]:
"""Parse a deck list from plain text or native XML."""
parser = DeckParser()
if text.strip().startswith("<mtgonline_deck"):
return parser.from_native_xml(text)
else:
return parser.parse_plain_text(text)
def format_deck(cards: List[CardInfo], format_type: str = "plain", prefix_sideboard: bool = True) -> str:
"""Format cards to specified deck format."""
parser = DeckParser()
if format_type == "native":
return parser.to_native_xml(cards)
else:
return parser.format_plain_text(cards, prefix_sideboard)