mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
719 lines
33 KiB
Python
719 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from deck_builder.builder import DeckBuilder
|
|
from deck_builder import builder_constants as bc
|
|
from file_setup.setup import initial_setup
|
|
from tagging import tagger
|
|
|
|
def _is_stale(file1: str, file2: str) -> bool:
|
|
"""Return True if file2 is missing or older than file1."""
|
|
if not os.path.isfile(file2):
|
|
return True
|
|
if not os.path.isfile(file1):
|
|
return True
|
|
return os.path.getmtime(file2) < os.path.getmtime(file1)
|
|
|
|
def _ensure_data_ready():
|
|
cards_csv = os.path.join("csv_files", "cards.csv")
|
|
tagging_json = os.path.join("csv_files", ".tagging_complete.json")
|
|
# If cards.csv is missing, run full setup+tagging
|
|
if not os.path.isfile(cards_csv):
|
|
print("cards.csv not found, running full setup and tagging...")
|
|
initial_setup()
|
|
tagger.run_tagging()
|
|
_write_tagging_flag(tagging_json)
|
|
# If tagging_complete is missing or stale, run tagging
|
|
elif not os.path.isfile(tagging_json) or _is_stale(cards_csv, tagging_json):
|
|
print(".tagging_complete.json missing or stale, running tagging...")
|
|
tagger.run_tagging()
|
|
_write_tagging_flag(tagging_json)
|
|
|
|
def _write_tagging_flag(tagging_json):
|
|
import json
|
|
from datetime import datetime
|
|
os.makedirs(os.path.dirname(tagging_json), exist_ok=True)
|
|
with open(tagging_json, 'w', encoding='utf-8') as f:
|
|
json.dump({'tagged_at': datetime.now().isoformat(timespec='seconds')}, f)
|
|
|
|
def run(
|
|
command_name: str = "",
|
|
add_creatures: bool = True,
|
|
add_non_creature_spells: bool = True,
|
|
add_ramp: bool = True,
|
|
add_removal: bool = True,
|
|
add_wipes: bool = True,
|
|
add_card_advantage: bool = True,
|
|
add_protection: bool = True,
|
|
primary_choice: int = 1,
|
|
secondary_choice: Optional[int] = None,
|
|
tertiary_choice: Optional[int] = None,
|
|
add_lands: bool = True,
|
|
fetch_count: Optional[int] = 3,
|
|
dual_count: Optional[int] = None,
|
|
triple_count: Optional[int] = None,
|
|
utility_count: Optional[int] = None,
|
|
ideal_counts: Optional[Dict[str, int]] = None,
|
|
bracket_level: Optional[int] = None,
|
|
# Include/Exclude configuration (M1: Config + Validation + Persistence)
|
|
include_cards: Optional[List[str]] = None,
|
|
exclude_cards: Optional[List[str]] = None,
|
|
enforcement_mode: str = "warn",
|
|
allow_illegal: bool = False,
|
|
fuzzy_matching: bool = True,
|
|
seed: Optional[int | str] = None,
|
|
) -> DeckBuilder:
|
|
"""Run a scripted non-interactive deck build and return the DeckBuilder instance."""
|
|
scripted_inputs: List[str] = []
|
|
# Commander query & selection
|
|
scripted_inputs.append(command_name) # initial query
|
|
scripted_inputs.append("1") # choose first search match to inspect
|
|
scripted_inputs.append("y") # confirm commander
|
|
# Primary tag selection
|
|
scripted_inputs.append(str(primary_choice))
|
|
# Secondary tag selection or stop (0)
|
|
if secondary_choice is not None:
|
|
scripted_inputs.append(str(secondary_choice))
|
|
# Tertiary tag selection or stop (0)
|
|
if tertiary_choice is not None:
|
|
scripted_inputs.append(str(tertiary_choice))
|
|
else:
|
|
scripted_inputs.append("0")
|
|
else:
|
|
scripted_inputs.append("0") # stop at primary
|
|
# Bracket (meta power / style) selection; default to 3 if not provided
|
|
scripted_inputs.append(str(bracket_level if isinstance(bracket_level, int) and 1 <= bracket_level <= 5 else 3))
|
|
# Ideal count prompts (press Enter for defaults). Include fetch_lands if present.
|
|
ideal_keys = {
|
|
"ramp",
|
|
"lands",
|
|
"basic_lands",
|
|
"fetch_lands",
|
|
"creatures",
|
|
"removal",
|
|
"wipes",
|
|
"card_advantage",
|
|
"protection",
|
|
}
|
|
for key in bc.DECK_COMPOSITION_PROMPTS.keys():
|
|
if key in ideal_keys:
|
|
scripted_inputs.append("")
|
|
|
|
def scripted_input(prompt: str) -> str:
|
|
if scripted_inputs:
|
|
return scripted_inputs.pop(0)
|
|
# Fallback to auto-accept defaults for any unexpected prompts
|
|
return ""
|
|
|
|
builder = DeckBuilder(input_func=scripted_input)
|
|
# Optional deterministic seed for Random Modes (does not affect core when unset)
|
|
try:
|
|
if seed is not None:
|
|
builder.set_seed(seed) # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
# Mark this run as headless so builder can adjust exports and logging
|
|
try:
|
|
builder.headless = True # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
|
|
# Configure include/exclude settings (M1: Config + Validation + Persistence)
|
|
try:
|
|
builder.include_cards = list(include_cards or []) # type: ignore[attr-defined]
|
|
builder.exclude_cards = list(exclude_cards or []) # type: ignore[attr-defined]
|
|
builder.enforcement_mode = enforcement_mode # type: ignore[attr-defined]
|
|
builder.allow_illegal = allow_illegal # type: ignore[attr-defined]
|
|
builder.fuzzy_matching = fuzzy_matching # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
|
|
# If ideal_counts are provided (from JSON), use them as the current defaults
|
|
# so the step 2 prompts will show these values and our blank entries will accept them.
|
|
if isinstance(ideal_counts, dict) and ideal_counts:
|
|
try:
|
|
ic: Dict[str, int] = {}
|
|
for k, v in ideal_counts.items():
|
|
try:
|
|
iv = int(v) if v is not None else None # type: ignore
|
|
except Exception:
|
|
continue
|
|
if iv is None:
|
|
continue
|
|
# Only accept known keys
|
|
if k in {"ramp","lands","basic_lands","creatures","removal","wipes","card_advantage","protection"}:
|
|
ic[k] = iv
|
|
if ic:
|
|
builder.ideal_counts.update(ic) # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
builder.run_initial_setup()
|
|
builder.run_deck_build_step1()
|
|
builder.run_deck_build_step2()
|
|
|
|
# Land sequence (optional)
|
|
if add_lands:
|
|
def call(method: str, **kwargs: Any) -> None:
|
|
fn = getattr(builder, method, None)
|
|
if callable(fn):
|
|
try:
|
|
fn(**kwargs)
|
|
except Exception:
|
|
pass
|
|
for method, kwargs in [
|
|
("run_land_step1", {}),
|
|
("run_land_step2", {}),
|
|
("run_land_step3", {}),
|
|
("run_land_step4", {"requested_count": fetch_count}),
|
|
("run_land_step5", {"requested_count": dual_count}),
|
|
("run_land_step6", {"requested_count": triple_count}),
|
|
("run_land_step7", {"requested_count": utility_count}),
|
|
("run_land_step8", {}),
|
|
]:
|
|
call(method, **kwargs)
|
|
|
|
if add_creatures:
|
|
builder.add_creatures()
|
|
# Non-creature spell categories (ramp / removal / wipes / draw / protection)
|
|
did_bulk = False
|
|
if add_non_creature_spells and hasattr(builder, "add_non_creature_spells"):
|
|
try:
|
|
builder.add_non_creature_spells()
|
|
did_bulk = True
|
|
except Exception:
|
|
did_bulk = False
|
|
if not did_bulk:
|
|
for method, flag in [
|
|
("add_ramp", add_ramp),
|
|
("add_removal", add_removal),
|
|
("add_board_wipes", add_wipes),
|
|
("add_card_advantage", add_card_advantage),
|
|
("add_protection", add_protection),
|
|
]:
|
|
if flag:
|
|
fn = getattr(builder, method, None)
|
|
if callable(fn):
|
|
try:
|
|
fn()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
builder.post_spell_land_adjust()
|
|
_export_outputs(builder)
|
|
return builder
|
|
|
|
def _should_export_json_headless() -> bool:
|
|
return os.getenv('HEADLESS_EXPORT_JSON', '').strip().lower() in {'1','true','yes','on'}
|
|
|
|
def _print_include_exclude_summary(builder: DeckBuilder) -> None:
|
|
"""Print include/exclude summary to console (M4: Extended summary printing)."""
|
|
if not hasattr(builder, 'include_exclude_diagnostics') or not builder.include_exclude_diagnostics:
|
|
return
|
|
|
|
diagnostics = builder.include_exclude_diagnostics
|
|
|
|
# Skip if no include/exclude activity
|
|
if not any([
|
|
diagnostics.get('include_cards'),
|
|
diagnostics.get('exclude_cards'),
|
|
diagnostics.get('include_added'),
|
|
diagnostics.get('excluded_removed')
|
|
]):
|
|
return
|
|
|
|
print("\n" + "=" * 50)
|
|
print("INCLUDE/EXCLUDE SUMMARY")
|
|
print("=" * 50)
|
|
|
|
# Include cards impact
|
|
include_cards = diagnostics.get('include_cards', [])
|
|
if include_cards:
|
|
print(f"\n✓ Must Include Cards ({len(include_cards)}):")
|
|
|
|
include_added = diagnostics.get('include_added', [])
|
|
if include_added:
|
|
print(f" ✓ Successfully Added ({len(include_added)}):")
|
|
for card in include_added:
|
|
print(f" • {card}")
|
|
|
|
missing_includes = diagnostics.get('missing_includes', [])
|
|
if missing_includes:
|
|
print(f" ⚠ Could Not Include ({len(missing_includes)}):")
|
|
for card in missing_includes:
|
|
print(f" • {card}")
|
|
|
|
# Exclude cards impact
|
|
exclude_cards = diagnostics.get('exclude_cards', [])
|
|
if exclude_cards:
|
|
print(f"\n✗ Must Exclude Cards ({len(exclude_cards)}):")
|
|
|
|
excluded_removed = diagnostics.get('excluded_removed', [])
|
|
if excluded_removed:
|
|
print(f" ✓ Successfully Excluded ({len(excluded_removed)}):")
|
|
for card in excluded_removed:
|
|
print(f" • {card}")
|
|
|
|
print(" Patterns:")
|
|
for pattern in exclude_cards:
|
|
print(f" • {pattern}")
|
|
|
|
# Validation issues
|
|
issues = []
|
|
fuzzy_corrections = diagnostics.get('fuzzy_corrections', {})
|
|
if fuzzy_corrections:
|
|
issues.append(f"Fuzzy Matched ({len(fuzzy_corrections)})")
|
|
|
|
duplicates = diagnostics.get('duplicates_collapsed', {})
|
|
if duplicates:
|
|
issues.append(f"Duplicates Collapsed ({len(duplicates)})")
|
|
|
|
illegal_dropped = diagnostics.get('illegal_dropped', [])
|
|
if illegal_dropped:
|
|
issues.append(f"Illegal Cards Dropped ({len(illegal_dropped)})")
|
|
|
|
if issues:
|
|
print("\n⚠ Validation Issues:")
|
|
|
|
if fuzzy_corrections:
|
|
print(" ⚡ Fuzzy Matched:")
|
|
for original, corrected in fuzzy_corrections.items():
|
|
print(f" • {original} → {corrected}")
|
|
|
|
if duplicates:
|
|
print(" Duplicates Collapsed:")
|
|
for card, count in duplicates.items():
|
|
print(f" • {card} ({count}x)")
|
|
|
|
if illegal_dropped:
|
|
print(" Illegal Cards Dropped:")
|
|
for card in illegal_dropped:
|
|
print(f" • {card}")
|
|
|
|
print("=" * 50)
|
|
|
|
|
|
def _export_outputs(builder: DeckBuilder) -> None:
|
|
# M4: Print include/exclude summary to console
|
|
_print_include_exclude_summary(builder)
|
|
|
|
csv_path: Optional[str] = None
|
|
try:
|
|
csv_path = builder.export_decklist_csv() if hasattr(builder, "export_decklist_csv") else None
|
|
except Exception:
|
|
csv_path = None
|
|
try:
|
|
if hasattr(builder, "export_decklist_text"):
|
|
if csv_path:
|
|
base = os.path.splitext(os.path.basename(csv_path))[0]
|
|
builder.export_decklist_text(filename=base + ".txt")
|
|
else:
|
|
builder.export_decklist_text()
|
|
except Exception:
|
|
pass
|
|
if _should_export_json_headless() and hasattr(builder, "export_run_config_json") and csv_path:
|
|
try:
|
|
base = os.path.splitext(os.path.basename(csv_path))[0]
|
|
dest = os.getenv("DECK_CONFIG")
|
|
if dest and dest.lower().endswith(".json"):
|
|
out_dir, out_name = os.path.dirname(dest) or ".", os.path.basename(dest)
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
builder.export_run_config_json(directory=out_dir, filename=out_name)
|
|
else:
|
|
out_dir = (dest if dest and os.path.isdir(dest) else "config")
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
builder.export_run_config_json(directory=out_dir, filename=base + ".json")
|
|
except Exception:
|
|
pass
|
|
|
|
def _parse_bool(val: Optional[str | bool | int]) -> Optional[bool]:
|
|
if val is None:
|
|
return None
|
|
if isinstance(val, bool):
|
|
return val
|
|
if isinstance(val, int):
|
|
return bool(val)
|
|
s = str(val).strip().lower()
|
|
if s in {"1", "true", "t", "yes", "y", "on"}:
|
|
return True
|
|
if s in {"0", "false", "f", "no", "n", "off"}:
|
|
return False
|
|
return None
|
|
|
|
|
|
def _parse_card_list(val: Optional[str]) -> List[str]:
|
|
"""Parse comma or semicolon-separated card list from CLI argument."""
|
|
if not val:
|
|
return []
|
|
|
|
# Support semicolon separation for card names with commas
|
|
if ';' in val:
|
|
return [card.strip() for card in val.split(';') if card.strip()]
|
|
|
|
# Use the intelligent parsing for comma-separated (handles card names with commas)
|
|
try:
|
|
from deck_builder.include_exclude_utils import parse_card_list_input
|
|
return parse_card_list_input(val)
|
|
except ImportError:
|
|
# Fallback to simple comma split if import fails
|
|
return [card.strip() for card in val.split(',') if card.strip()]
|
|
|
|
|
|
def _parse_opt_int(val: Optional[str | int]) -> Optional[int]:
|
|
if val is None:
|
|
return None
|
|
if isinstance(val, int):
|
|
return val
|
|
s = str(val).strip().lower()
|
|
if s in {"", "none", "null", "nan"}:
|
|
return None
|
|
return int(s)
|
|
|
|
|
|
def _load_json_config(path: Optional[str]) -> Dict[str, Any]:
|
|
if not path:
|
|
return {}
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if not isinstance(data, dict):
|
|
raise ValueError("JSON config must be an object")
|
|
return data
|
|
except FileNotFoundError:
|
|
raise
|
|
|
|
|
|
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(description="Headless deck builder runner")
|
|
p.add_argument("--config", metavar="PATH", default=os.getenv("DECK_CONFIG"),
|
|
help="Path to JSON config file (string)")
|
|
p.add_argument("--commander", metavar="NAME", default=None,
|
|
help="Commander name to search for (string)")
|
|
p.add_argument("--primary-choice", metavar="INT", type=int, default=None,
|
|
help="Primary theme tag choice number (integer)")
|
|
p.add_argument("--secondary-choice", metavar="INT", type=_parse_opt_int, default=None,
|
|
help="Secondary theme tag choice number (integer, optional)")
|
|
p.add_argument("--tertiary-choice", metavar="INT", type=_parse_opt_int, default=None,
|
|
help="Tertiary theme tag choice number (integer, optional)")
|
|
p.add_argument("--primary-tag", metavar="NAME", default=None,
|
|
help="Primary theme tag name (string, alternative to --primary-choice)")
|
|
p.add_argument("--secondary-tag", metavar="NAME", default=None,
|
|
help="Secondary theme tag name (string, alternative to --secondary-choice)")
|
|
p.add_argument("--tertiary-tag", metavar="NAME", default=None,
|
|
help="Tertiary theme tag name (string, alternative to --tertiary-choice)")
|
|
p.add_argument("--bracket-level", metavar="1-5", type=int, default=None,
|
|
help="Power bracket level 1-5 (integer)")
|
|
|
|
# Ideal count arguments - new feature!
|
|
ideal_group = p.add_argument_group("Ideal Deck Composition",
|
|
"Override default target counts for deck categories")
|
|
ideal_group.add_argument("--ramp-count", metavar="INT", type=int, default=None,
|
|
help="Target number of ramp spells (integer, default: 8)")
|
|
ideal_group.add_argument("--land-count", metavar="INT", type=int, default=None,
|
|
help="Target total number of lands (integer, default: 35)")
|
|
ideal_group.add_argument("--basic-land-count", metavar="INT", type=int, default=None,
|
|
help="Minimum number of basic lands (integer, default: 15)")
|
|
ideal_group.add_argument("--creature-count", metavar="INT", type=int, default=None,
|
|
help="Target number of creatures (integer, default: 25)")
|
|
ideal_group.add_argument("--removal-count", metavar="INT", type=int, default=None,
|
|
help="Target number of spot removal spells (integer, default: 10)")
|
|
ideal_group.add_argument("--wipe-count", metavar="INT", type=int, default=None,
|
|
help="Target number of board wipes (integer, default: 2)")
|
|
ideal_group.add_argument("--card-advantage-count", metavar="INT", type=int, default=None,
|
|
help="Target number of card advantage pieces (integer, default: 10)")
|
|
ideal_group.add_argument("--protection-count", metavar="INT", type=int, default=None,
|
|
help="Target number of protection spells (integer, default: 8)")
|
|
|
|
# Land-specific counts
|
|
land_group = p.add_argument_group("Land Configuration",
|
|
"Control specific land type counts and options")
|
|
land_group.add_argument("--add-lands", metavar="BOOL", type=_parse_bool, default=None,
|
|
help="Whether to add lands (bool: true/false/1/0)")
|
|
land_group.add_argument("--fetch-count", metavar="INT", type=_parse_opt_int, default=None,
|
|
help="Number of fetch lands to include (integer, optional)")
|
|
land_group.add_argument("--dual-count", metavar="INT", type=_parse_opt_int, default=None,
|
|
help="Number of dual lands to include (integer, optional)")
|
|
land_group.add_argument("--triple-count", metavar="INT", type=_parse_opt_int, default=None,
|
|
help="Number of triple lands to include (integer, optional)")
|
|
land_group.add_argument("--utility-count", metavar="INT", type=_parse_opt_int, default=None,
|
|
help="Number of utility lands to include (integer, optional)")
|
|
|
|
# Card type toggles
|
|
toggle_group = p.add_argument_group("Card Type Toggles",
|
|
"Enable/disable adding specific card types")
|
|
toggle_group.add_argument("--add-creatures", metavar="BOOL", type=_parse_bool, default=None,
|
|
help="Add creatures to deck (bool: true/false/1/0)")
|
|
toggle_group.add_argument("--add-non-creature-spells", metavar="BOOL", type=_parse_bool, default=None,
|
|
help="Add non-creature spells to deck (bool: true/false/1/0)")
|
|
toggle_group.add_argument("--add-ramp", metavar="BOOL", type=_parse_bool, default=None,
|
|
help="Add ramp spells to deck (bool: true/false/1/0)")
|
|
toggle_group.add_argument("--add-removal", metavar="BOOL", type=_parse_bool, default=None,
|
|
help="Add removal spells to deck (bool: true/false/1/0)")
|
|
toggle_group.add_argument("--add-wipes", metavar="BOOL", type=_parse_bool, default=None,
|
|
help="Add board wipes to deck (bool: true/false/1/0)")
|
|
toggle_group.add_argument("--add-card-advantage", metavar="BOOL", type=_parse_bool, default=None,
|
|
help="Add card advantage pieces to deck (bool: true/false/1/0)")
|
|
toggle_group.add_argument("--add-protection", metavar="BOOL", type=_parse_bool, default=None,
|
|
help="Add protection spells to deck (bool: true/false/1/0)")
|
|
|
|
# Include/Exclude configuration
|
|
include_group = p.add_argument_group("Include/Exclude Cards",
|
|
"Force include or exclude specific cards")
|
|
include_group.add_argument("--include-cards", metavar="CARDS",
|
|
help='Cards to force include (string: comma-separated, max 10). For cards with commas in names like "Krenko, Mob Boss", use semicolons or JSON config.')
|
|
include_group.add_argument("--exclude-cards", metavar="CARDS",
|
|
help='Cards to exclude from deck (string: comma-separated, max 15). For cards with commas in names like "Krenko, Mob Boss", use semicolons or JSON config.')
|
|
include_group.add_argument("--enforcement-mode", metavar="MODE", choices=["warn", "strict"], default=None,
|
|
help="How to handle missing includes (string: warn=continue, strict=abort)")
|
|
include_group.add_argument("--allow-illegal", metavar="BOOL", type=_parse_bool, default=None,
|
|
help="Allow illegal cards in includes/excludes (bool: true/false/1/0)")
|
|
include_group.add_argument("--fuzzy-matching", metavar="BOOL", type=_parse_bool, default=None,
|
|
help="Enable fuzzy card name matching (bool: true/false/1/0)")
|
|
|
|
# Utility
|
|
p.add_argument("--dry-run", action="store_true",
|
|
help="Print resolved configuration and exit without building")
|
|
return p
|
|
|
|
|
|
def _resolve_value(
|
|
cli: Optional[Any], env_name: str, json_data: Dict[str, Any], json_key: str, default: Any
|
|
) -> Any:
|
|
if cli is not None:
|
|
return cli
|
|
env_val = os.getenv(env_name)
|
|
if env_val is not None:
|
|
# Convert types based on default type
|
|
if isinstance(default, bool):
|
|
b = _parse_bool(env_val)
|
|
return default if b is None else b
|
|
if isinstance(default, int) or default is None:
|
|
# allow optional ints
|
|
try:
|
|
return _parse_opt_int(env_val)
|
|
except ValueError:
|
|
return default
|
|
return env_val
|
|
if json_key in json_data:
|
|
return json_data[json_key]
|
|
return default
|
|
|
|
|
|
def _main() -> int:
|
|
_ensure_data_ready()
|
|
parser = _build_arg_parser()
|
|
args = parser.parse_args()
|
|
# Optional config discovery (no prompts)
|
|
cfg_path = args.config
|
|
json_cfg: Dict[str, Any] = {}
|
|
if cfg_path and os.path.isfile(cfg_path):
|
|
json_cfg = _load_json_config(cfg_path)
|
|
else:
|
|
# No explicit file; if exactly one config exists in a known dir, use it
|
|
for candidate_dir in [cfg_path] if cfg_path and os.path.isdir(cfg_path) else ["/app/config", "config"]:
|
|
try:
|
|
files = [f for f in (os.listdir(candidate_dir) if os.path.isdir(candidate_dir) else []) if f.lower().endswith(".json")]
|
|
except Exception:
|
|
files = []
|
|
if len(files) == 1:
|
|
chosen = os.path.join(candidate_dir, files[0])
|
|
json_cfg = _load_json_config(chosen)
|
|
os.environ["DECK_CONFIG"] = chosen
|
|
break
|
|
|
|
# Defaults mirror run() signature
|
|
defaults = dict(
|
|
command_name="",
|
|
add_creatures=True,
|
|
add_non_creature_spells=True,
|
|
add_ramp=True,
|
|
add_removal=True,
|
|
add_wipes=True,
|
|
add_card_advantage=True,
|
|
add_protection=True,
|
|
primary_choice=1,
|
|
secondary_choice=None,
|
|
tertiary_choice=None,
|
|
add_lands=True,
|
|
fetch_count=3,
|
|
dual_count=None,
|
|
triple_count=None,
|
|
utility_count=None,
|
|
)
|
|
|
|
# Pull optional ideal_counts from JSON if present
|
|
ideal_counts_json = {}
|
|
try:
|
|
if isinstance(json_cfg.get("ideal_counts"), dict):
|
|
ideal_counts_json = json_cfg["ideal_counts"]
|
|
except Exception:
|
|
ideal_counts_json = {}
|
|
|
|
# Build ideal_counts dict from CLI args, JSON, or defaults
|
|
ideal_counts_resolved = {}
|
|
ideal_mappings = [
|
|
("ramp_count", "ramp", 8),
|
|
("land_count", "lands", 35),
|
|
("basic_land_count", "basic_lands", 15),
|
|
("creature_count", "creatures", 25),
|
|
("removal_count", "removal", 10),
|
|
("wipe_count", "wipes", 2),
|
|
("card_advantage_count", "card_advantage", 10),
|
|
("protection_count", "protection", 8),
|
|
]
|
|
|
|
for cli_key, json_key, default_val in ideal_mappings:
|
|
cli_val = getattr(args, cli_key, None)
|
|
if cli_val is not None:
|
|
ideal_counts_resolved[json_key] = cli_val
|
|
elif json_key in ideal_counts_json:
|
|
ideal_counts_resolved[json_key] = ideal_counts_json[json_key]
|
|
# Don't set defaults here - let the builder use its own defaults
|
|
|
|
# Pull include/exclude configuration from JSON (M1: Config + Validation + Persistence)
|
|
include_cards_json = []
|
|
exclude_cards_json = []
|
|
try:
|
|
if isinstance(json_cfg.get("include_cards"), list):
|
|
include_cards_json = [str(x) for x in json_cfg["include_cards"] if x]
|
|
if isinstance(json_cfg.get("exclude_cards"), list):
|
|
exclude_cards_json = [str(x) for x in json_cfg["exclude_cards"] if x]
|
|
except Exception:
|
|
pass
|
|
|
|
# M4: Parse CLI include/exclude card lists
|
|
cli_include_cards = _parse_card_list(args.include_cards) if hasattr(args, 'include_cards') else []
|
|
cli_exclude_cards = _parse_card_list(args.exclude_cards) if hasattr(args, 'exclude_cards') else []
|
|
|
|
# Resolve tag names to indices BEFORE building resolved dict (so they can override defaults)
|
|
resolved_primary_choice = args.primary_choice
|
|
resolved_secondary_choice = args.secondary_choice
|
|
resolved_tertiary_choice = args.tertiary_choice
|
|
|
|
try:
|
|
# Collect tag names from CLI, JSON, and environment (CLI takes precedence)
|
|
primary_tag_name = (
|
|
args.primary_tag or
|
|
(str(os.getenv("DECK_PRIMARY_TAG") or "").strip()) or
|
|
str(json_cfg.get("primary_tag", "")).strip()
|
|
)
|
|
secondary_tag_name = (
|
|
args.secondary_tag or
|
|
(str(os.getenv("DECK_SECONDARY_TAG") or "").strip()) or
|
|
str(json_cfg.get("secondary_tag", "")).strip()
|
|
)
|
|
tertiary_tag_name = (
|
|
args.tertiary_tag or
|
|
(str(os.getenv("DECK_TERTIARY_TAG") or "").strip()) or
|
|
str(json_cfg.get("tertiary_tag", "")).strip()
|
|
)
|
|
|
|
tag_names = [t for t in [primary_tag_name, secondary_tag_name, tertiary_tag_name] if t]
|
|
if tag_names:
|
|
# Load commander name to resolve tags
|
|
commander_name = _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", "")
|
|
if commander_name:
|
|
try:
|
|
# Load commander tags to compute indices
|
|
tmp = DeckBuilder()
|
|
df = tmp.load_commander_data()
|
|
row = df[df["name"] == commander_name]
|
|
if not row.empty:
|
|
original = list(dict.fromkeys(row.iloc[0].get("themeTags", []) or []))
|
|
|
|
# Step 1: primary from original
|
|
if primary_tag_name:
|
|
for i, t in enumerate(original, start=1):
|
|
if str(t).strip().lower() == primary_tag_name.strip().lower():
|
|
resolved_primary_choice = i
|
|
break
|
|
|
|
# Step 2: secondary from remaining after primary
|
|
if secondary_tag_name:
|
|
if resolved_primary_choice is not None:
|
|
# Create remaining list after removing primary choice
|
|
remaining_1 = [t for j, t in enumerate(original, start=1) if j != resolved_primary_choice]
|
|
for i2, t in enumerate(remaining_1, start=1):
|
|
if str(t).strip().lower() == secondary_tag_name.strip().lower():
|
|
resolved_secondary_choice = i2
|
|
break
|
|
else:
|
|
# If no primary set, secondary maps directly to original list
|
|
for i, t in enumerate(original, start=1):
|
|
if str(t).strip().lower() == secondary_tag_name.strip().lower():
|
|
resolved_secondary_choice = i
|
|
break
|
|
|
|
# Step 3: tertiary from remaining after primary+secondary
|
|
if tertiary_tag_name:
|
|
if resolved_primary_choice is not None and resolved_secondary_choice is not None:
|
|
# reconstruct remaining after removing primary then secondary as displayed
|
|
remaining_1 = [t for j, t in enumerate(original, start=1) if j != resolved_primary_choice]
|
|
remaining_2 = [t for j, t in enumerate(remaining_1, start=1) if j != resolved_secondary_choice]
|
|
for i3, t in enumerate(remaining_2, start=1):
|
|
if str(t).strip().lower() == tertiary_tag_name.strip().lower():
|
|
resolved_tertiary_choice = i3
|
|
break
|
|
elif resolved_primary_choice is not None:
|
|
# Only primary set, tertiary from remaining after primary
|
|
remaining_1 = [t for j, t in enumerate(original, start=1) if j != resolved_primary_choice]
|
|
for i, t in enumerate(remaining_1, start=1):
|
|
if str(t).strip().lower() == tertiary_tag_name.strip().lower():
|
|
resolved_tertiary_choice = i
|
|
break
|
|
else:
|
|
# No primary or secondary set, tertiary maps directly to original list
|
|
for i, t in enumerate(original, start=1):
|
|
if str(t).strip().lower() == tertiary_tag_name.strip().lower():
|
|
resolved_tertiary_choice = i
|
|
break
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
resolved = {
|
|
"command_name": _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", defaults["command_name"]),
|
|
"add_creatures": _resolve_value(args.add_creatures, "DECK_ADD_CREATURES", json_cfg, "add_creatures", defaults["add_creatures"]),
|
|
"add_non_creature_spells": _resolve_value(args.add_non_creature_spells, "DECK_ADD_NON_CREATURE_SPELLS", json_cfg, "add_non_creature_spells", defaults["add_non_creature_spells"]),
|
|
"add_ramp": _resolve_value(args.add_ramp, "DECK_ADD_RAMP", json_cfg, "add_ramp", defaults["add_ramp"]),
|
|
"add_removal": _resolve_value(args.add_removal, "DECK_ADD_REMOVAL", json_cfg, "add_removal", defaults["add_removal"]),
|
|
"add_wipes": _resolve_value(args.add_wipes, "DECK_ADD_WIPES", json_cfg, "add_wipes", defaults["add_wipes"]),
|
|
"add_card_advantage": _resolve_value(args.add_card_advantage, "DECK_ADD_CARD_ADVANTAGE", json_cfg, "add_card_advantage", defaults["add_card_advantage"]),
|
|
"add_protection": _resolve_value(args.add_protection, "DECK_ADD_PROTECTION", json_cfg, "add_protection", defaults["add_protection"]),
|
|
"primary_choice": _resolve_value(resolved_primary_choice, "DECK_PRIMARY_CHOICE", json_cfg, "primary_choice", defaults["primary_choice"]),
|
|
"secondary_choice": _resolve_value(resolved_secondary_choice, "DECK_SECONDARY_CHOICE", json_cfg, "secondary_choice", defaults["secondary_choice"]),
|
|
"tertiary_choice": _resolve_value(resolved_tertiary_choice, "DECK_TERTIARY_CHOICE", json_cfg, "tertiary_choice", defaults["tertiary_choice"]),
|
|
"bracket_level": _resolve_value(args.bracket_level, "DECK_BRACKET_LEVEL", json_cfg, "bracket_level", None),
|
|
"add_lands": _resolve_value(args.add_lands, "DECK_ADD_LANDS", json_cfg, "add_lands", defaults["add_lands"]),
|
|
"fetch_count": _resolve_value(args.fetch_count, "DECK_FETCH_COUNT", json_cfg, "fetch_count", defaults["fetch_count"]),
|
|
"dual_count": _resolve_value(args.dual_count, "DECK_DUAL_COUNT", json_cfg, "dual_count", defaults["dual_count"]),
|
|
"triple_count": _resolve_value(args.triple_count, "DECK_TRIPLE_COUNT", json_cfg, "triple_count", defaults["triple_count"]),
|
|
"utility_count": _resolve_value(args.utility_count, "DECK_UTILITY_COUNT", json_cfg, "utility_count", defaults["utility_count"]),
|
|
"ideal_counts": ideal_counts_resolved,
|
|
# M4: Include/Exclude configuration (CLI + JSON + Env priority)
|
|
"include_cards": cli_include_cards or include_cards_json,
|
|
"exclude_cards": cli_exclude_cards or exclude_cards_json,
|
|
"enforcement_mode": args.enforcement_mode or json_cfg.get("enforcement_mode", "warn"),
|
|
"allow_illegal": args.allow_illegal if args.allow_illegal is not None else bool(json_cfg.get("allow_illegal", False)),
|
|
"fuzzy_matching": args.fuzzy_matching if args.fuzzy_matching is not None else bool(json_cfg.get("fuzzy_matching", True)),
|
|
}
|
|
|
|
if args.dry_run:
|
|
print(json.dumps(resolved, indent=2))
|
|
return 0
|
|
|
|
if not str(resolved.get("command_name", "")).strip():
|
|
print("Error: commander is required. Provide --commander or a JSON config with a 'commander' field.")
|
|
return 2
|
|
|
|
run(**resolved)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(_main())
|