mtg/tools/similar.py
mark rosenberg d4a8a587f0 Restructure into a multi-deck engine; generalize tools/ off Jund/Reaper hardcodes
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
2026-09-12 00:37:42 -04:00

48 lines
1.7 KiB
Python

#!/usr/bin/env python3
"""Find mechanically similar cards.
python3 tools/similar.py --deck decks/reaper-king-no-more "Card Name" [-n 15]
"""
import sys, os, json, numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from deckctx import DeckContext, ROOT
def load():
z = np.load(os.path.join(ROOT, "data", "embeddings.npz"), allow_pickle=True)
return list(z["names"]), z["embedding"]
def main():
ctx = DeckContext.from_args()
args = sys.argv[1:]
if not args: sys.exit(__doc__)
card = args[0]
k = int(args[args.index("-n")+1]) if "-n" in args else 15
names, emb = load()
idx = {n: i for i, n in enumerate(names)}
if card not in idx:
near = [n for n in names if card.lower() in n.lower()][:5]
sys.exit(f"not found: {card}" + (f"\ndid you mean: {near}" if near else ""))
# color-identity + legality filter from the oracle bulk, derived from
# this deck's own commander -- never a hardcoded color combination
from cards import DB
db = DB(blacklist_path=ctx.blacklist_path)
identity = ctx.identity(db)
ci, legal = {}, {}
for l in open(os.path.join(ROOT, "data", "oracle-cards.jsonl")):
c = json.loads(l)
ci[c["name"]] = set(c.get("color_identity", []))
legal[c["name"]] = c.get("legalities", {}).get("commander") == "legal"
sims = emb @ emb[idx[card]]
print(f"~~ mechanically similar to {card} ({''.join(sorted(identity))}-legal only) ~~")
for i in np.argsort(-sims):
n = names[i]
if n == card or not legal.get(n) or not ci.get(n, {"W"}) <= identity: continue
print(f" {sims[i]:.3f} {n}")
k -= 1
if k == 0: break
if __name__ == "__main__":
main()