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