214 lines
8.3 KiB
Python
214 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate deck variants automatically from a role budget.
|
|
|
|
A variant is a *reallocation*: shift N slots from one role to another, then let
|
|
the tool pick which specific cards to cut and add. Cuts come from the weakest
|
|
cards in the over-served role; adds come from the best unused cards in the
|
|
under-served one.
|
|
|
|
python3 tools/generate.py --deck decks/reaper-king-no-more --list
|
|
python3 tools/generate.py --deck decks/reaper-king-no-more
|
|
python3 tools/generate.py --deck decks/reaper-king-no-more --only aggro-hate
|
|
python3 tools/generate.py --deck decks/reaper-king-no-more --from-stats decks/reaper-king-no-more/decks/fast-ramp/card_stats.txt
|
|
|
|
Every generated deck is validated: 100 cards, singleton, on-identity, no
|
|
blacklisted cards. Anything that fails is reported and not written.
|
|
|
|
Recipes (the hypotheses -- which roles to reallocate and why) are deck-specific
|
|
content, not code: they live in that deck's variants.yaml (config.yaml
|
|
`variants_file:`), not hardcoded here. Protected cards -- the ones the
|
|
generator may never cut -- come from config.yaml's `protected:` list.
|
|
"""
|
|
import sys, os, re, json
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from cards import DB, load_decklist
|
|
from deckctx import DeckContext
|
|
from roles import classify, profile, pool, ROLES
|
|
from collections import Counter
|
|
|
|
|
|
def load_recipes(ctx):
|
|
"""{name: (why, {role: delta})} from this deck's variants.yaml."""
|
|
vfile = ctx.path(ctx.config.get("variants_file", "variants.yaml"))
|
|
if not os.path.exists(vfile):
|
|
return {}
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
sys.exit("pyyaml required to read variants.yaml")
|
|
spec = yaml.safe_load(open(vfile)) or {}
|
|
out = {}
|
|
for name, r in (spec.get("recipes") or {}).items():
|
|
out[name] = (r.get("why", "").strip(), dict(r.get("deltas") or {}))
|
|
return out
|
|
|
|
|
|
def load_base(base_path):
|
|
_, deck = load_decklist(base_path)
|
|
return deck
|
|
|
|
|
|
def weakest(names, role, db, n, protect):
|
|
"""Which cards to cut from a role: worst EDHREC rank first, never protected."""
|
|
cands = [db[x] for x in names
|
|
if db.get(x) and not db[x].is_land
|
|
and classify(db[x], db) == role and x not in protect]
|
|
cands.sort(key=lambda c: -(c.edhrec_rank or 10**9)) # worst first
|
|
return [c.name for c in cands[:n]]
|
|
|
|
|
|
def creates_vacuum_combo(candidate, deck, db, commander):
|
|
"""Would adding this card create a two-card infinite that needs no board state?
|
|
|
|
This is the house rule, enforced at generation time so bad candidates are
|
|
never picked rather than caught afterwards.
|
|
"""
|
|
_, idx = db.combos
|
|
present = set(deck) | {commander}
|
|
for combo in idx.get(candidate, []):
|
|
uses = [u["card"]["name"] for u in combo["uses"]]
|
|
if len(uses) != 2 or candidate not in uses:
|
|
continue
|
|
other = [u for u in uses if u != candidate]
|
|
if not other or other[0] not in present:
|
|
continue
|
|
if (combo.get("notablePrerequisites") or "").strip():
|
|
continue # needs board state -> allowed
|
|
if any("Infinite" in p["feature"]["name"] or "wins the game" in p["feature"]["name"]
|
|
for p in combo["produces"]):
|
|
return True
|
|
return False
|
|
|
|
|
|
def build(name, why, deltas, base, db, protect, commander, identity, stats=None):
|
|
deck = list(base)
|
|
cuts, adds = [], []
|
|
|
|
for role, d in sorted(deltas.items(), key=lambda kv: kv[1]):
|
|
if d >= 0:
|
|
continue
|
|
picked = weakest(deck, role, db, -d, protect)
|
|
if len(picked) < -d:
|
|
return None, f"cannot cut {-d} from '{role}' (only {len(picked)} available)"
|
|
for p in picked:
|
|
deck.remove(p)
|
|
cuts += picked
|
|
|
|
used = set(deck) | {commander} | set(cuts)
|
|
for role, d in sorted(deltas.items(), key=lambda kv: -kv[1]):
|
|
if d <= 0:
|
|
continue
|
|
cands = pool(role, db, identity, exclude=used)
|
|
picked = []
|
|
for c in cands:
|
|
if len(picked) == d:
|
|
break
|
|
if creates_vacuum_combo(c.name, deck, db, commander):
|
|
continue # house rule: never generate a vacuum combo
|
|
picked.append(c.name)
|
|
deck.append(c.name)
|
|
if len(picked) < d:
|
|
return None, f"only {len(picked)} usable candidates for '{role}', need {d}"
|
|
adds += picked
|
|
used |= set(picked)
|
|
|
|
return {"deck": deck, "cuts": cuts, "adds": adds, "why": why}, None
|
|
|
|
|
|
def validate(deck, db, identity):
|
|
errs = []
|
|
if len(deck) != 99:
|
|
errs.append(f"{len(deck)+1} cards, need 100")
|
|
dupes = {n: c for n, c in Counter(deck).items()
|
|
if c > 1 and db.get(n) and "Basic" not in db[n].type_line}
|
|
if dupes:
|
|
errs.append(f"singleton violated: {dupes}")
|
|
off = [n for n in deck if db.get(n) and not db[n].color_identity <= identity]
|
|
if off:
|
|
errs.append(f"off-identity: {off}")
|
|
bl = [n for n in deck if n in db.blacklist]
|
|
if bl:
|
|
errs.append(f"blacklisted: {bl}")
|
|
unknown = [n for n in deck if not db.get(n)]
|
|
if unknown:
|
|
errs.append(f"unknown: {unknown}")
|
|
return errs
|
|
|
|
|
|
def never_cast(stats_path):
|
|
"""Cards a previous sim never cast -- prime cut candidates."""
|
|
if not stats_path or not os.path.exists(stats_path):
|
|
return set()
|
|
out, grab = set(), False
|
|
for line in open(stats_path):
|
|
if line.startswith("NEVER CAST"):
|
|
grab = True; continue
|
|
if grab:
|
|
if line.startswith("-") or line.strip().startswith("("):
|
|
continue
|
|
if line.strip() and line.startswith(" "):
|
|
out.add(line.strip())
|
|
elif line.strip() == "":
|
|
break
|
|
return out
|
|
|
|
|
|
def main():
|
|
ctx = DeckContext.from_args() # pops --deck/-d out of sys.argv first
|
|
args = sys.argv[1:]
|
|
only = args[args.index("--only") + 1] if "--only" in args else None
|
|
stats = args[args.index("--from-stats") + 1] if "--from-stats" in args else None
|
|
base_path = args[args.index("--base") + 1] if "--base" in args else \
|
|
ctx.path(ctx.config.get("generate_base", "decks/no-mikaeus/decklist.mgt"))
|
|
|
|
recipes = load_recipes(ctx)
|
|
if "--list" in args:
|
|
if not recipes:
|
|
print(f"no recipes -- add them to {ctx.path(ctx.config.get('variants_file', 'variants.yaml'))}")
|
|
for n, (why, d) in recipes.items():
|
|
print(f"{n:18} {d}")
|
|
print(f"{'':18} {why}")
|
|
return
|
|
|
|
db = DB(blacklist_path=ctx.blacklist_path)
|
|
commander = ctx.commander
|
|
identity = ctx.identity(db)
|
|
base = load_base(base_path)
|
|
# never cut the deck's identity-defining cards -- from config.yaml `protected:`
|
|
protect = ctx.protected
|
|
skip = never_cast(stats)
|
|
if skip:
|
|
print(f"(using {len(skip)} never-cast cards as preferred cuts)\n")
|
|
|
|
for name, (why, deltas) in recipes.items():
|
|
if only and name != only:
|
|
continue
|
|
assert sum(deltas.values()) == 0, f"{name}: deltas must sum to 0"
|
|
res, err = build(name, why, deltas, base, db, protect, commander, identity, skip)
|
|
if err:
|
|
print(f"{name:18} SKIPPED — {err}")
|
|
continue
|
|
errs = validate(res["deck"], db, identity)
|
|
if errs:
|
|
print(f"{name:18} INVALID — {'; '.join(errs)}")
|
|
continue
|
|
|
|
vdir = ctx.path("decks", name)
|
|
os.makedirs(vdir, exist_ok=True)
|
|
base_rel = os.path.relpath(base_path, ctx.dir)
|
|
header = [f"# {name}", f"# {why}", "",
|
|
f"# generated by tools/generate.py from {base_rel}",
|
|
f"# roles: {deltas}", "",
|
|
"# Commander", f"1 {commander}", "", "# Main"]
|
|
body = [f"1 {n}" for n in sorted(set(res["deck"]) - {"Swamp", "Forest", "Mountain"})]
|
|
basics = [f"{Counter(res['deck'])[b]} {b}" for b in ("Swamp", "Forest", "Mountain")
|
|
if Counter(res["deck"])[b]]
|
|
open(os.path.join(vdir, "decklist.mgt"), "w").write(
|
|
"\n".join(header + body + basics) + "\n")
|
|
print(f"{name:18} ok -{len(res['cuts'])} +{len(res['adds'])}")
|
|
print(f"{'':18} cut: {', '.join(res['cuts'][:4])}{'...' if len(res['cuts'])>4 else ''}")
|
|
print(f"{'':18} add: {', '.join(res['adds'][:4])}{'...' if len(res['adds'])>4 else ''}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|