Initial commit of mtgonline project
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""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 Cockatrice 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 Cockatrice native XML format."""
|
||||
xml_lines = ['<cockatrice_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('</cockatrice_deck>')
|
||||
return "\n".join(xml_lines)
|
||||
|
||||
def from_native_xml(self, xml: str) -> List[CardInfo]:
|
||||
"""Parse Cockatrice 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("<cockatrice_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)
|
||||
Reference in New Issue
Block a user