mtg/tools/to_dck.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

126 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""Convert decklist.txt -> Forge .dck format.
python3 tools/to_dck.py --deck decks/reaper-king-no-more
python3 tools/to_dck.py --deck decks/reaper-king-no-more [decklist.txt] [-o ~/forge/decks/<slug>.dck]
Forge sections are [Commander], [Main], [Sideboard] (verified in DeckSection.java).
Card names must match Forge's own database, so this validates against
res/cardsfolder and reports anything Forge won't recognise BEFORE you sim.
Output filename and [metadata] Name= are both derived from the deck (slug /
config.yaml `name:`) -- never a hardcoded "reaper.dck" / deck title.
"""
import sys, os, re, zipfile
sys.path.insert(0, os.path.dirname(__file__))
from cards import load_decklist
from deckctx import DeckContext
from collections import Counter
def load_subs(subs_file, enabled=True):
"""Simulation-only card swaps for cards Forge's AI genuinely cannot pilot.
Returns {real_card: stand_in}. See <deck>/sim_substitutions.yaml for the
rationale and the deviations each swap introduces.
"""
if not enabled or not subs_file or not os.path.exists(subs_file):
return {}
try:
import yaml
except ImportError:
print("warning: pyyaml missing, substitutions skipped", file=sys.stderr)
return {}
d = yaml.safe_load(open(subs_file)) or {}
return d.get("substitutions") or {}
FORGE = os.path.expanduser("~/forge")
def forge_card_names():
"""Every card name Forge knows, from its cardsfolder (dir or .zip)."""
names = set()
folder = os.path.join(FORGE, "res", "cardsfolder")
zpath = os.path.join(folder, "cardsfolder.zip")
if os.path.exists(zpath):
with zipfile.ZipFile(zpath) as z:
for n in z.namelist():
if not n.endswith(".txt"):
continue
with z.open(n) as f:
for line in f:
line = line.decode("utf-8", "ignore").strip()
if line.startswith("Name:"):
names.add(line[5:].strip()); break
for root, _, files in os.walk(folder):
for fn in files:
if not fn.endswith(".txt"):
continue
try:
with open(os.path.join(root, fn), encoding="utf-8", errors="ignore") as f:
for line in f:
if line.startswith("Name:"):
names.add(line[5:].strip()); break
except OSError:
pass
return names
def main():
ctx = DeckContext.from_args()
args = [a for a in sys.argv[1:]]
out = args[args.index("-o") + 1] if "-o" in args else \
os.path.join(FORGE, "decks", ctx.dck_filename())
src = next((a for a in args if a.endswith(".txt")), ctx.decklist_path)
commander, deck = load_decklist(src)
subs_file = ctx.path(ctx.config.get("sim_substitutions_file", "sim_substitutions.yaml"))
subs = load_subs(subs_file, enabled="--no-subs" not in args)
applied = {}
if subs:
present = set(deck)
swapped = [] # NB: not `out` -- that's the output path
for n in deck:
t = subs.get(n)
# never substitute into a duplicate: that would break singleton
if t and t not in present:
applied[n] = t
present.add(t)
swapped.append(t)
else:
if t:
print(f"note: not substituting {n} -> {t} "
f"({t} already in deck)", file=sys.stderr)
swapped.append(n)
deck = swapped
if applied:
print(f"substituted {len(applied)} AI-unpilotable card(s) for simulation:",
file=sys.stderr)
for a, b in applied.items():
print(f" {a} -> {b}", file=sys.stderr)
print(" (per-card stats for these describe the STAND-IN, not the real card)",
file=sys.stderr)
known = forge_card_names()
if known:
missing = sorted({n for n in [commander] + deck if n not in known})
if missing:
print(f"!! {len(missing)} card(s) Forge does not recognise:", file=sys.stderr)
for m in missing:
print(f" {m}", file=sys.stderr)
print(" (fix the name in decklist.txt, or Forge will drop it)\n", file=sys.stderr)
else:
print("warning: could not read Forge's card list; skipping validation", file=sys.stderr)
lines = ["[metadata]", f"Name={ctx.forge_metadata_name()}", "", "[Commander]", f"1 {commander}", "", "[Main]"]
for name, count in sorted(Counter(deck).items()):
lines.append(f"{count} {name}")
lines += ["", "[Sideboard]", ""]
os.makedirs(os.path.dirname(out), exist_ok=True)
open(out, "w").write("\n".join(lines) + "\n")
print(f"wrote {out} ({len(deck)} + 1 commander)")
return 0
if __name__ == "__main__":
sys.exit(main())