Migrates the reaper-king-no-more proof of concept into decks/reaper-king-no-more/
as the first deck under a shared tools/ engine, so the repo can hold many
Commander decks instead of one implicit one.
- tools/deckctx.py: new shared DeckContext, resolves --deck / $MTG_DECK / cwd,
loads a deck's config.yaml, and derives color identity from the commander's
own card data (never a hardcoded color combination).
- cards.py: drop the JUND=frozenset("BRG") constant; jund_legal() renamed to
identity_legal(card, identity); DB() takes an explicit blacklist_path since
the blacklist is per-deck, not shared.
- combos.py, roles.py, similar.py, similar_text.py, bracket_check.py: take
--deck, use the deck's real identity instead of Jund/BRG.
- power.py, card_stats.py, forge_report.py: OURS deck-name constant now comes
from the deck's config (name: / commander), matched via DeckContext instead
of hardcoded "Reaper King No More".
- to_dck.py, batch_run.py, ab_test.py: .dck filename and [metadata] Name= are
derived from the deck (slug / config name), not a hardcoded "reaper.dck".
- generate.py: RECIPES and make_variants.py's VARIANTS (deck-specific
hypotheses) moved out of Python into decks/reaper-king-no-more/variants.yaml;
protected cards now come from config.yaml's protected: list instead of a
hardcoded set.
- New tools/mtg dispatcher: `tools/mtg decks/<name> <tool> [args...]` runs any
tool against any deck without cd-ing into its directory.
- data/ (Scryfall/MTGJSON/Spellbook bulk, ~1.2GB, gitignored) and embeddings.npz
move to the repo root as shared, commander-format-wide data.
- docs/howto.md: moved from the deck dir (it documents the tool, not the deck)
and updated its example commands to the real generalized CLI.
- README.md: rewritten to describe the multi-deck repo and point at
decks/reaper-king-no-more as the reference example.
Verified after the change: bracket_check, combos, and roles against
reaper-king-no-more reproduce the exact PASS/11-combos/role-profile numbers
from before the move; generate.py --list now loads recipes from YAML;
make_variants.py reproduces all 7 hand-curated variants including the
mikaeus FAIL control case; to_dck.py derives the right Name= and filename;
variant.py and compare.py reproduce decks/README.md's original numbers
exactly. The original POC at ~/mtg/reaper-king-no-more is untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPKgLYpMR8szWNFXyK1XNu
75 lines
2.9 KiB
Python
75 lines
2.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Offline combo finder — reimplements Commander Spellbook's find-my-combos
|
|
against the local variants.json. No API calls.
|
|
|
|
python3 tools/combos.py --deck decks/reaper-king-no-more
|
|
python3 tools/combos.py --deck decks/reaper-king-no-more --almost 30
|
|
python3 tools/combos.py --deck decks/reaper-king-no-more --deck-file some/decklist.txt
|
|
"""
|
|
import sys, os, json
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
from cards import DB, load_decklist
|
|
from deckctx import DeckContext
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
limit = int(args[args.index("--almost") + 1]) if "--almost" in args else 15
|
|
# --deck-file lets a caller (e.g. variant.py) point at a specific decklist
|
|
# inside the deck dir while --deck/-d (consumed by DeckContext) still
|
|
# selects which deck's config/identity to use.
|
|
explicit_deck_file = args[args.index("--deck-file") + 1] if "--deck-file" in args else None
|
|
ctx = DeckContext.from_args(args, pop=False)
|
|
deck_path = explicit_deck_file or ctx.decklist_path
|
|
db = DB(blacklist_path=ctx.blacklist_path)
|
|
identity = ctx.identity(db)
|
|
commander, deck = load_decklist(deck_path)
|
|
pool = set(deck) | {commander}
|
|
variants, _ = db.combos
|
|
|
|
included, almost = [], []
|
|
for c in variants:
|
|
if not set(c["identity"]) <= identity:
|
|
continue
|
|
uses = [u["card"]["name"] for u in c["uses"]]
|
|
have = pool & set(uses)
|
|
missing = set(uses) - pool
|
|
if not have:
|
|
continue
|
|
if not missing:
|
|
included.append((uses, c))
|
|
elif len(missing) == 1:
|
|
m = next(iter(missing))
|
|
if m in db.blacklist: # never suggest a blacklisted card
|
|
continue
|
|
almost.append((m, uses, c))
|
|
|
|
print("=" * 66)
|
|
print(f"COMBOS ALREADY IN THE DECK: {len(included)}")
|
|
print("=" * 66)
|
|
for uses, c in sorted(included, key=lambda x: len(x[0])):
|
|
print(f" [{c['bracketTag']}] {' + '.join(uses)}")
|
|
for p in c["produces"][:3]:
|
|
print(f" -> {p['feature']['name']}")
|
|
if c.get("notablePrerequisites"):
|
|
for line in c["notablePrerequisites"].splitlines():
|
|
print(f" requires: {line}")
|
|
print()
|
|
|
|
# rank "one card away" by how many combos that single card unlocks
|
|
from collections import Counter
|
|
unlocks = Counter(m for m, _, _ in almost)
|
|
print("=" * 66)
|
|
print(f"ONE CARD AWAY: {len(almost)} combos, {len(unlocks)} distinct cards")
|
|
print("=" * 66)
|
|
for name, n in unlocks.most_common(limit):
|
|
card = db.get(name)
|
|
rank = f"#{card.edhrec_rank}" if card and card.edhrec_rank else "-"
|
|
cost = card.mana_cost if card else "?"
|
|
print(f" {n:>3} combos {name:<34} {cost:<12} {rank}")
|
|
print()
|
|
print(f"decklist: {deck_path} | source: data/variants.json (108,535 combos)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|