mtg/tools/roles.py
2026-09-12 00:41:47 -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.mgt
"""
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()