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
126 lines
4.8 KiB
Python
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())
|