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
108 lines
4.3 KiB
Python
108 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Role model: what job does each card do, and what does a deck need?
|
|
|
|
A Commander deck is a budget allocated across roles. A variant is a different
|
|
allocation. This module defines the roles (as verified Scryfall Tagger slugs plus
|
|
oracle-text fallbacks) and can classify any card or score a whole decklist.
|
|
|
|
python3 tools/roles.py --deck decks/reaper-king-no-more
|
|
python3 tools/roles.py --deck decks/reaper-king-no-more decks/reaper-king-no-more/decks/fast-ramp/decklist.txt
|
|
"""
|
|
import sys, os
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from cards import DB, load_decklist
|
|
from deckctx import DeckContext
|
|
from collections import Counter
|
|
|
|
# role -> (tag slugs, oracle-text fallbacks). Order matters: first match wins,
|
|
# so the most deck-specific roles are listed first.
|
|
ROLES = {
|
|
"minus_engine": ([], ["-1/-1 counter"]),
|
|
"sac_outlet": (["free-sacrifice-outlet", "sacrifice-outlet-universal"], []),
|
|
"sac_payoff": (["your-sacrifice-matters"], []),
|
|
"opp_death": (["death-trigger-opponent"], []),
|
|
"drain": (["blood-artist-ability", "drain-life", "opponent-loses-life"], []),
|
|
"death_trigger": (["death-trigger-self", "death-trigger", "life-and-death-trigger-self"], []),
|
|
"tokens": (["repeatable-creature-tokens"], []),
|
|
"reanimate": (["reanimate-creature"], []),
|
|
"proliferate": (["repeatable-proliferate", "pseudo-proliferate"], []),
|
|
"ramp": (["ramp"], ["Search your library", "{T}: Add"]),
|
|
"draw": (["draw-engine", "repeatable-pure-draw", "burst-draw"], []),
|
|
"removal_mass": (["sweeper", "multi-removal"], []),
|
|
"removal_spot": (["spot-removal", "removal-creature", "repeatable-removal"], []),
|
|
"tutor": (["tutor-to-hand", "tutor-to-battlefield"], []),
|
|
}
|
|
|
|
# What the baseline aims for. A variant is a deviation from this.
|
|
TARGET = {
|
|
"minus_engine": 16, "sac_outlet": 6, "sac_payoff": 4, "opp_death": 7,
|
|
"drain": 5, "death_trigger": 8, "ramp": 9, "draw": 6,
|
|
"removal_mass": 3, "removal_spot": 8, "tokens": 2, "proliferate": 2,
|
|
"reanimate": 1, "tutor": 0,
|
|
}
|
|
|
|
|
|
def classify(card, db):
|
|
"""The single role a card most plausibly fills. Lands are excluded upstream."""
|
|
for role, (slugs, texts) in ROLES.items():
|
|
if any(s in card.tags for s in slugs):
|
|
return role
|
|
if any(t in card.oracle for t in texts):
|
|
return role
|
|
return "other"
|
|
|
|
|
|
def profile(deck_names, db):
|
|
"""Counter of role -> number of cards, for a decklist's nonland cards."""
|
|
c = Counter()
|
|
for n in deck_names:
|
|
card = db.get(n)
|
|
if not card or card.is_land:
|
|
continue
|
|
c[classify(card, db)] += 1
|
|
return c
|
|
|
|
|
|
def pool(role, db, identity, exclude=(), max_rank=8000, max_mv=None):
|
|
"""Candidate cards for a role: identity-legal, not blacklisted, not already used."""
|
|
slugs, texts = ROLES[role]
|
|
out = []
|
|
for card in db.cards.values():
|
|
if card.is_land or not db.identity_legal(card, identity):
|
|
continue
|
|
if card.name in db.blacklist or card.name in exclude:
|
|
continue
|
|
if (card.edhrec_rank or 10**9) > max_rank:
|
|
continue
|
|
if max_mv is not None and card.mana_value > max_mv:
|
|
continue
|
|
if classify(card, db) != role:
|
|
continue
|
|
out.append(card)
|
|
return sorted(out, key=lambda c: c.edhrec_rank or 10**9)
|
|
|
|
|
|
def main():
|
|
ctx = DeckContext.from_args()
|
|
db = DB(blacklist_path=ctx.blacklist_path)
|
|
args = sys.argv[1:]
|
|
path = next((a for a in args if not a.startswith("-")), None) or ctx.decklist_path
|
|
target = ctx.config.get("roles", TARGET)
|
|
_, deck = load_decklist(path)
|
|
p = profile(deck, db)
|
|
lands = sum(1 for n in deck if db.get(n) and db[n].is_land)
|
|
|
|
print(f"ROLE PROFILE — {path}")
|
|
print("=" * 56)
|
|
print(f" {'role':<16}{'have':>6}{'target':>8}{'diff':>7}")
|
|
for role in list(ROLES) + ["other"]:
|
|
have, want = p.get(role, 0), target.get(role, 0)
|
|
d = have - want
|
|
flag = "" if abs(d) <= 1 or role == "other" else (" <-- over" if d > 0 else " <-- under")
|
|
print(f" {role:<16}{have:>6}{want:>8}{d:>+7}{flag}")
|
|
print(f" {'lands':<16}{lands:>6}")
|
|
print(f" {'TOTAL':<16}{sum(p.values())+lands:>6}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|