207 lines
7.3 KiB
Python
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 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)
|