mtg/tools/cards.py
mark rosenberg d4a8a587f0 Restructure into a multi-deck engine; generalize tools/ off Jund/Reaper hardcodes
Migrates the reaper-king-no-more proof of concept into decks/reaper-king-no-more/
as the first deck under a shared tools/ engine, so the repo can hold many
Commander decks instead of one implicit one.

- tools/deckctx.py: new shared DeckContext, resolves --deck / $MTG_DECK / cwd,
  loads a deck's config.yaml, and derives color identity from the commander's
  own card data (never a hardcoded color combination).
- cards.py: drop the JUND=frozenset("BRG") constant; jund_legal() renamed to
  identity_legal(card, identity); DB() takes an explicit blacklist_path since
  the blacklist is per-deck, not shared.
- combos.py, roles.py, similar.py, similar_text.py, bracket_check.py: take
  --deck, use the deck's real identity instead of Jund/BRG.
- power.py, card_stats.py, forge_report.py: OURS deck-name constant now comes
  from the deck's config (name: / commander), matched via DeckContext instead
  of hardcoded "Reaper King No More".
- to_dck.py, batch_run.py, ab_test.py: .dck filename and [metadata] Name= are
  derived from the deck (slug / config name), not a hardcoded "reaper.dck".
- generate.py: RECIPES and make_variants.py's VARIANTS (deck-specific
  hypotheses) moved out of Python into decks/reaper-king-no-more/variants.yaml;
  protected cards now come from config.yaml's protected: list instead of a
  hardcoded set.
- New tools/mtg dispatcher: `tools/mtg decks/<name> <tool> [args...]` runs any
  tool against any deck without cd-ing into its directory.
- data/ (Scryfall/MTGJSON/Spellbook bulk, ~1.2GB, gitignored) and embeddings.npz
  move to the repo root as shared, commander-format-wide data.
- docs/howto.md: moved from the deck dir (it documents the tool, not the deck)
  and updated its example commands to the real generalized CLI.
- README.md: rewritten to describe the multi-deck repo and point at
  decks/reaper-king-no-more as the reference example.

Verified after the change: bracket_check, combos, and roles against
reaper-king-no-more reproduce the exact PASS/11-combos/role-profile numbers
from before the move; generate.py --list now loads recipes from YAML;
make_variants.py reproduces all 7 hand-curated variants including the
mikaeus FAIL control case; to_dck.py derives the right Name= and filename;
variant.py and compare.py reproduce decks/README.md's original numbers
exactly. The original POC at ~/mtg/reaper-king-no-more is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPKgLYpMR8szWNFXyK1XNu
2026-09-12 00:37:42 -04:00

235 lines
9.2 KiB
Python

#!/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$ <name> -- 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)