#!/usr/bin/env python3 """Shared card index: joins Scryfall oracle + Tagger tags + MTGJSON edhrecRank + the Commander Spellbook combo DB into one in-memory object. Every other tool builds on this. from cards import DB db = DB() db["Necroskitter"].tags -> {'death-trigger-opponent', ...} db.tagged("free-sacrifice-outlet") -> [Card, ...] db.identity_legal(card, identity) -> bool Color identity is never hardcoded here -- it's always passed in by the caller, derived from whichever commander that deck's config.yaml names (see tools/deckctx.py DeckContext.identity()). """ import json, os, functools, re, zipfile from dataclasses import dataclass, field DATA = os.path.join(os.path.dirname(__file__), "..", "data") @dataclass(eq=False) # identity hash: cards are unique by object class Card: name: str mana_cost: str = "" mana_value: float = 0.0 type_line: str = "" oracle: str = "" color_identity: frozenset = frozenset() legal_commander: bool = False game_changer: bool = False edhrec_rank: int | None = None tags: set = field(default_factory=set) # Forge card-script facts (see DB._load_forge_scripts) ai_removed: bool = False # AI:RemoveDeck:All -- Forge will NEVER play it ai_logic: str | None = None # AILogic$ -- has a bespoke AI routine ai_sac_pref: bool = False # SVar:AIPreference:SacCost -- knows what to sac @property def is_land(self): return "Land" in self.type_line def __repr__(self): return f"<{self.name} {self.mana_cost}>" class DB: def __init__(self, data_dir=DATA, blacklist_path=None): self.dir = data_dir self.cards: dict[str, Card] = {} self._by_tag: dict[str, list] = {} self._load_oracle() self._load_tags() self._load_edhrec() # blacklist is per-deck (a deck's own preferences), not shared data -- # callers normally pass DeckContext.blacklist_path. With no path given # this loads nothing, which is a safe default for deck-agnostic tools. self.blacklist = load_blacklist(blacklist_path) if blacklist_path else {} self._load_forge_scripts() # ---------- loading ---------- def _load_oracle(self): for line in open(f"{self.dir}/oracle-cards.jsonl"): c = json.loads(line) if c.get("layout") in ("token", "double_faced_token", "art_series", "emblem"): continue faces = c.get("card_faces") or [] self.cards[c["name"]] = Card( name=c["name"], mana_cost=c.get("mana_cost") or " // ".join(f.get("mana_cost", "") for f in faces), mana_value=c.get("cmc", 0.0), type_line=c.get("type_line", ""), oracle=c.get("oracle_text") or "\n".join(f.get("oracle_text", "") for f in faces), color_identity=frozenset(c.get("color_identity", [])), legal_commander=c.get("legalities", {}).get("commander") == "legal", game_changer=bool(c.get("game_changer")), ) self.cards[c["name"]]._oracle_id = c.get("oracle_id") def _load_tags(self): by_oid = {} for line in open(f"{self.dir}/oracle-tags.jsonl"): t = json.loads(line) for tg in t["taggings"]: by_oid.setdefault(tg["oracle_id"], set()).add(t["slug"]) for card in self.cards.values(): card.tags = by_oid.get(getattr(card, "_oracle_id", None), set()) for slug in card.tags: self._by_tag.setdefault(slug, []).append(card) def _load_edhrec(self): atomic = json.load(open(f"{self.dir}/AtomicCards.json"))["data"] for name, printings in atomic.items(): c = self.cards.get(name) if c and printings: c.edhrec_rank = printings[0].get("edhrecRank") def _load_forge_scripts(self, forge=None): """Read Forge's card scripts to learn which cards its AI can actually play. `AI:RemoveDeck:All` means Forge is forbidden from ever playing the card -- 2,525 cards carry it. Those show up as "never cast" in sim results, which is a SIMULATOR ARTIFACT, not evidence the card is bad. Distinguishing the two is the whole point of this method. """ forge = forge or os.path.expanduser("~/forge") zpath = os.path.join(forge, "res", "cardsfolder", "cardsfolder.zip") if not os.path.exists(zpath): return cache = os.path.join(self.dir, ".forge_scripts.json") if os.path.exists(cache) and os.path.getmtime(cache) > os.path.getmtime(zpath): info = json.load(open(cache)) else: info = {} with zipfile.ZipFile(zpath) as z: for n in z.namelist(): if not n.endswith(".txt"): continue try: txt = z.read(n).decode("utf-8", "ignore") except Exception: continue first = txt.split("\n", 1)[0] if not first.startswith("Name:"): continue name = first[5:].strip() m = re.search(r"AILogic\$\s*([A-Za-z.]+)", txt) info[name] = { "ai_removed": "AI:RemoveDeck:All" in txt, "ai_logic": m.group(1) if m else None, "ai_sac_pref": "AIPreference:SacCost" in txt, } try: json.dump(info, open(cache, "w")) except OSError: pass for name, d in info.items(): c = self.cards.get(name) if c: c.ai_removed = d["ai_removed"] c.ai_logic = d["ai_logic"] c.ai_sac_pref = d["ai_sac_pref"] # ---------- queries ---------- def __getitem__(self, name): return self.cards[name] def get(self, name): return self.cards.get(name) def tagged(self, *slugs, identity=None, legal=True, allow_blacklisted=False): """Cards carrying ALL given tags, within a color identity. `identity` is a frozenset of color letters (e.g. frozenset("BRG")), normally from DeckContext.identity() -- never a hardcoded combination. identity=None means no color filter at all. Blacklisted cards are omitted unless allow_blacklisted=True. """ out = None for s in slugs: hits = set(self._by_tag.get(s, [])) out = hits if out is None else out & hits out = out or set() res = [c for c in out if (identity is None or c.color_identity <= identity) and (not legal or c.legal_commander) and (allow_blacklisted or c.name not in self.blacklist)] return sorted(res, key=lambda c: c.edhrec_rank or 10**9) def identity_legal(self, c, identity): """Is this card legal in Commander and within the given color identity?""" return c.legal_commander and c.color_identity <= identity @functools.cached_property def combos(self): """Commander Spellbook variants, indexed by card name.""" v = json.load(open(f"{self.dir}/variants.json"))["variants"] idx = {} for combo in v: for u in combo["uses"]: idx.setdefault(u["card"]["name"], []).append(combo) return v, idx def load_blacklist(path="blacklist.txt"): """Cards excluded by personal preference. Returns {name: reason}. Distinct from legality: these are choices, not rules. Kept in one file so both the checker and the search tools honour them. """ out = {} if not os.path.exists(path): return out for raw in open(path): line = raw.strip() if not line or line.startswith("#"): continue name, _, reason = line.partition("|") out[name.strip()] = reason.strip() or "no reason given" return out def load_decklist(path): """Parse a decklist. '# Commander' / '# Main' headers select the section. Lines are 'N Card Name' or just 'Card Name'. Returns (commander, [names]).""" commander, main, section = None, [], "main" for raw in open(path): line = raw.strip() if not line: continue if line.startswith("#"): # only top-level '# ' headers switch sections; '## ' are just comments if not line.startswith("##"): low = line.lower() if "commander" in low: section = "commander" elif "main" in low or "deck" in low: section = "main" continue parts = line.split(None, 1) if parts[0].isdigit() and len(parts) > 1: n, name = int(parts[0]), parts[1].strip() else: n, name = 1, line if section == "commander": commander = name else: main.extend([name] * n) return commander, main if __name__ == "__main__": db = DB() print(f"{len(db.cards)} cards, {len(db._by_tag)} tags") print("gamechangers:", sum(1 for c in db.cards.values() if c.game_changer)) for c in db.tagged("death-trigger-opponent")[:5]: print(" ", c.edhrec_rank, c.name)