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
137 lines
4.7 KiB
Python
137 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Resolve "which deck am I operating on" the same way in every tool.
|
|
|
|
Every tool used to assume it was run with cwd == that one deck's directory.
|
|
Now every tool accepts an explicit deck path, so the repo can hold many decks:
|
|
|
|
python3 tools/bracket_check.py --deck decks/reaper-king-no-more
|
|
MTG_DECK=decks/reaper-king-no-more python3 tools/bracket_check.py
|
|
|
|
Resolution order for the deck directory:
|
|
1. --deck/-d PATH on the command line (removed from argv before the caller
|
|
does its own parsing)
|
|
2. $MTG_DECK environment variable
|
|
3. cwd, if it looks like a deck dir (has config.yaml) -- backwards compat
|
|
with the old "cd into the deck dir and run tools/x.py" workflow
|
|
4. error
|
|
|
|
from deckctx import DeckContext
|
|
ctx = DeckContext.from_args() # pops --deck out of sys.argv
|
|
ctx.path("decklist.txt") # -> decks/<name>/decklist.txt
|
|
ctx.commander # -> "The Reaper, King No More"
|
|
ctx.identity # -> frozenset("BRG"), from the DB
|
|
ctx.slug # -> "reaper-king-no-more"
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
def _looks_like_deck_dir(d):
|
|
return os.path.exists(os.path.join(d, "config.yaml")) or \
|
|
os.path.exists(os.path.join(d, "decklist.txt"))
|
|
|
|
|
|
def resolve_deck_dir(argv=None, pop=True):
|
|
"""Find the deck directory, optionally stripping --deck/-d out of argv."""
|
|
argv = sys.argv if argv is None else argv
|
|
for flag in ("--deck", "-d"):
|
|
if flag in argv:
|
|
i = argv.index(flag)
|
|
path = argv[i + 1]
|
|
if pop:
|
|
del argv[i:i + 2]
|
|
return os.path.abspath(path)
|
|
|
|
env = os.environ.get("MTG_DECK")
|
|
if env:
|
|
return os.path.abspath(env)
|
|
|
|
if _looks_like_deck_dir("."):
|
|
return os.path.abspath(".")
|
|
|
|
sys.exit("no deck specified -- pass --deck decks/<name>, set $MTG_DECK, "
|
|
"or run from inside a deck directory")
|
|
|
|
|
|
class DeckContext:
|
|
"""Everything a tool needs to know about the deck it's operating on."""
|
|
|
|
def __init__(self, deck_dir):
|
|
self.dir = os.path.abspath(deck_dir)
|
|
if not os.path.isdir(self.dir):
|
|
sys.exit(f"no such deck directory: {self.dir}")
|
|
self.config = self._load_config()
|
|
self._commander = self.config.get("commander")
|
|
|
|
@classmethod
|
|
def from_args(cls, argv=None, pop=True):
|
|
return cls(resolve_deck_dir(argv, pop))
|
|
|
|
def _load_config(self):
|
|
cfg_path = os.path.join(self.dir, "config.yaml")
|
|
if not os.path.exists(cfg_path):
|
|
return {}
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
print("warning: pyyaml missing, config.yaml ignored", file=sys.stderr)
|
|
return {}
|
|
return yaml.safe_load(open(cfg_path)) or {}
|
|
|
|
def path(self, *parts):
|
|
"""A path inside this deck's directory."""
|
|
return os.path.join(self.dir, *parts)
|
|
|
|
@property
|
|
def slug(self):
|
|
"""Directory basename, used as a filesystem-safe deck identifier."""
|
|
return os.path.basename(self.dir.rstrip("/"))
|
|
|
|
@property
|
|
def name(self):
|
|
"""Human-readable deck name. Falls back to the commander, then the slug."""
|
|
return self.config.get("name") or self._commander or self.slug
|
|
|
|
@property
|
|
def commander(self):
|
|
if not self._commander:
|
|
sys.exit(f"{self.path('config.yaml')}: no 'commander:' set")
|
|
return self._commander
|
|
|
|
@property
|
|
def decklist_path(self):
|
|
return self.config.get("decklist") and self.path(self.config["decklist"]) \
|
|
or self.path("decklist.txt")
|
|
|
|
@property
|
|
def blacklist_path(self):
|
|
return self.path(self.config.get("blacklist_file", "blacklist.txt"))
|
|
|
|
@property
|
|
def protected(self):
|
|
return set(self.config.get("protected", []))
|
|
|
|
def identity(self, db=None):
|
|
"""Color identity, derived from the commander's own card data.
|
|
|
|
Never hardcode a color combination -- look up the commander in the
|
|
oracle database and use its color_identity, full stop.
|
|
"""
|
|
if db is None:
|
|
from cards import DB
|
|
db = DB()
|
|
card = db.get(self.commander)
|
|
if card is None:
|
|
sys.exit(f"commander '{self.commander}' not found in card database "
|
|
f"-- check config.yaml / run tools/fetch_data.py")
|
|
return card.color_identity
|
|
|
|
def dck_filename(self):
|
|
"""Forge .dck filename derived from this deck's slug."""
|
|
return f"{self.slug}.dck"
|
|
|
|
def forge_metadata_name(self):
|
|
"""[metadata] Name= value for the .dck file."""
|
|
return self.name
|