""" decklist.py Parses a pasted decklist into (name, quantity) entries. Accepts the plain text formats players actually paste, rather than one strict format: Sol Ring 1 Sol Ring 1x Sol Ring 1 Sol Ring (CMM) 410 <- Moxfield / MTG Arena export 1 Sol Ring (CMM) 410 *F* <- foil / etched markers 1 Sol Ring [CMM] <- bracketed set code 1x Sol Ring (cmm) 410 [Ramp] <- Archidekt export with a category tag Section headers ("Commander", "Deck:", "// Creatures", ...) are skipped. Anything after a sideboard/maybeboard/considering header is ignored, since those cards aren't part of the deck being completed. Pure text handling only: resolving names against the card vocabulary happens in server/deckbuilder.py. """ from __future__ import annotations import re from dataclasses import dataclass _QTY_RE = re.compile(r"^(\d+)\s*x?\s+(.+)$", re.IGNORECASE) # Trailing "(SET) 123", "(SET)", "[SET]", "*F*"/"*E*" markers and Archidekt # "[Category]" / "^Tag^" annotations, stripped repeatedly from the right. _TRAILING_NOISE_RE = re.compile( r"\s+(?:\([A-Za-z0-9]{2,6}\)(?:\s+[A-Za-z0-9\-★]+)?|\[[^\]]*\]|\*[A-Za-z]\*|\^[^^]*\^)\s*$" ) _IGNORED_SECTION_RE = re.compile( r"^(sideboard|maybeboard|maybe|considering|tokens?)\s*(\(\d+\))?\s*:?$", re.IGNORECASE ) _HEADER_RE = re.compile( r"^(commanders?|companion|deck|main\s*deck|mainboard|" r"creatures?|instants?|sorcery|sorceries|artifacts?|enchantments?|planeswalkers?|" r"battles?|lands?|other|spells?)\s*(\(\d+\))?\s*:?$", re.IGNORECASE, ) @dataclass(frozen=True) class DecklistEntry: name: str quantity: int line_number: int # 1-based, for pointing the user at a bad line def _strip_trailing_noise(text: str) -> str: while True: stripped = _TRAILING_NOISE_RE.sub("", text) if stripped == text: return text.strip() text = stripped def parse_decklist(text: str) -> list[DecklistEntry]: """Returns one entry per card line, in input order. Duplicate names are NOT merged here -- the caller merges after name resolution, since two differently-spelled lines can resolve to the same card.""" entries: list[DecklistEntry] = [] for line_number, raw in enumerate(text.splitlines(), start=1): line = raw.strip() if not line or line.startswith("#"): continue # A "// Heading" or "SIDEBOARD:" style line: stop at ignored sections, # skip everything else that looks like a header. heading = line.lstrip("/").strip() if line.startswith("//") or line.endswith(":") or _HEADER_RE.match(heading): if _IGNORED_SECTION_RE.match(heading): break continue if _IGNORED_SECTION_RE.match(heading): break quantity = 1 m = _QTY_RE.match(line) if m: quantity = int(m.group(1)) line = m.group(2) name = _strip_trailing_noise(line) if name and quantity > 0: entries.append(DecklistEntry(name=name, quantity=quantity, line_number=line_number)) return entries