#!/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()