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
235 lines
9.2 KiB
Python
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)
|