#!/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.txt")) 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.txt"), "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()