mtg/tools/roles.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

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()