mtg_python_deckbuilder/code/headless_runner.py

1555 lines
61 KiB
Python
Raw Normal View History

from __future__ import annotations
import argparse
import json
import os
import re
from dataclasses import asdict, dataclass, field
from functools import lru_cache
from typing import Any, Dict, List, Optional, Tuple
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
from exceptions import CommanderValidationError
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 _headless_owned_cards_dir() -> str:
env_dir = os.getenv("OWNED_CARDS_DIR") or os.getenv("CARD_LIBRARY_DIR")
if env_dir:
return env_dir
if os.path.isdir("owned_cards"):
return "owned_cards"
if os.path.isdir("card_library"):
return "card_library"
return "owned_cards"
def _headless_list_owned_files() -> List[str]:
folder = _headless_owned_cards_dir()
entries: List[str] = []
try:
if os.path.isdir(folder):
for name in os.listdir(folder):
path = os.path.join(folder, name)
if os.path.isfile(path) and name.lower().endswith((".txt", ".csv")):
entries.append(path)
except Exception:
return []
return sorted(entries)
def _normalize_commander_name(value: Any) -> str:
return str(value or "").strip().casefold()
def _tokenize_commander_name(value: Any) -> List[str]:
normalized = _normalize_commander_name(value)
if not normalized:
return []
return [token for token in re.split(r"[^a-z0-9]+", normalized) if token]
@lru_cache(maxsize=1)
def _load_commander_name_lookup() -> Tuple[set[str], Tuple[str, ...]]:
builder = DeckBuilder(
headless=True,
log_outputs=False,
output_func=lambda *_: None,
input_func=lambda *_: "",
)
df = builder.load_commander_data()
raw_names: List[str] = []
for column in ("name", "faceName"):
if column not in df.columns:
continue
series = df[column].dropna().astype(str)
raw_names.extend(series.tolist())
normalized = {
norm
for norm in (_normalize_commander_name(name) for name in raw_names)
if norm
}
ordered_raw = tuple(dict.fromkeys(raw_names))
return normalized, ordered_raw
def _validate_commander_available(command_name: str) -> None:
normalized = _normalize_commander_name(command_name)
if not normalized:
return
available, raw_names = _load_commander_name_lookup()
if normalized in available:
return
query_tokens = _tokenize_commander_name(command_name)
for candidate in raw_names:
candidate_norm = _normalize_commander_name(candidate)
if not candidate_norm:
continue
if candidate_norm.startswith(normalized):
return
candidate_tokens = _tokenize_commander_name(candidate)
if query_tokens and all(token in candidate_tokens for token in query_tokens):
return
try:
from commander_exclusions import lookup_commander_detail as _lookup_commander_detail # type: ignore[import-not-found]
except ImportError: # pragma: no cover
_lookup_commander_detail = None
info = _lookup_commander_detail(command_name) if _lookup_commander_detail else None
if info is not None:
primary_face = str(info.get("primary_face") or info.get("name") or "").strip()
eligible_faces = info.get("eligible_faces")
face_hint = ", ".join(str(face) for face in eligible_faces) if isinstance(eligible_faces, list) else ""
message = (
f"Commander '{command_name}' is no longer available because only a secondary face met commander eligibility."
)
if primary_face and _normalize_commander_name(primary_face) != normalized:
message += f" Try selecting the front face '{primary_face}' or choose a different commander."
elif face_hint:
message += f" The remaining eligible faces were: {face_hint}."
else:
message += " Choose a different commander whose front face is commander-legal."
raise CommanderValidationError(message, details={"commander": command_name, "reason": info})
raise CommanderValidationError(f"Commander not found: {command_name}", details={"commander": command_name})
@dataclass
class RandomRunConfig:
"""Runtime options for the headless random build flow."""
legacy_theme: Optional[str] = None
primary_theme: Optional[str] = None
secondary_theme: Optional[str] = None
tertiary_theme: Optional[str] = None
auto_fill_missing: bool = False
auto_fill_secondary: Optional[bool] = None
auto_fill_tertiary: Optional[bool] = None
strict_theme_match: bool = False
attempts: int = 5
timeout_ms: int = 5000
seed: Optional[int | str] = None
constraints: Dict[str, Any] = field(default_factory=dict)
output_json: Optional[str] = None
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."""
trimmed_commander = (command_name or "").strip()
if trimmed_commander:
_validate_commander_available(trimmed_commander)
owned_prompt_inputs: List[str] = []
owned_files_available = _headless_list_owned_files()
if owned_files_available:
use_owned_flag = _parse_bool(os.getenv("HEADLESS_USE_OWNED_ONLY"))
if use_owned_flag:
owned_prompt_inputs.append("y")
selection = (os.getenv("HEADLESS_OWNED_SELECTION") or "").strip()
owned_prompt_inputs.append(selection)
else:
owned_prompt_inputs.append("n")
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
scripted_inputs.extend(owned_prompt_inputs)
# 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
# Persist for downstream reuse (e.g., random_entrypoint / reroll flows) so they don't re-export
if csv_path:
try:
builder.last_csv_path = csv_path # type: ignore[attr-defined]
except Exception:
pass
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]
txt_generated: Optional[str] = None
try:
txt_generated = builder.export_decklist_text(filename=base + ".txt")
finally:
if txt_generated:
try:
builder.last_txt_path = txt_generated # type: ignore[attr-defined]
except Exception:
pass
else:
txt_generated = None
try:
txt_generated = builder.export_decklist_text()
finally:
if txt_generated:
try:
builder.last_txt_path = txt_generated # type: ignore[attr-defined]
except Exception:
pass
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 _load_constraints_spec(spec: Any) -> Dict[str, Any]:
"""Load random constraints from a dict, JSON string, or file path."""
if not spec:
return {}
if isinstance(spec, dict):
return dict(spec)
try:
text = str(spec).strip()
except Exception:
return {}
if not text:
return {}
# Treat existing file paths as JSON documents
if os.path.isfile(text):
try:
with open(text, "r", encoding="utf-8") as fh:
loaded = json.load(fh)
if isinstance(loaded, dict):
return loaded
except Exception as exc:
print(f"Warning: failed to load constraints from '{text}': {exc}")
return {}
# Fallback: parse inline JSON
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
return parsed
except Exception as exc:
print(f"Warning: failed to parse inline constraints '{text}': {exc}")
return {}
def _try_convert_seed(value: Any) -> Optional[int | str]:
if value is None:
return None
if isinstance(value, int):
return value
try:
text = str(value).strip()
except Exception:
return None
if not text:
return None
try:
return int(text)
except ValueError:
return text
def _resolve_pathish_target(target: str, seed: Any) -> str:
"""Return a concrete file path for an output target, creating directories as needed."""
if not target:
raise ValueError("Empty output path provided")
normalized = target.strip()
if not normalized:
raise ValueError("Blank output path provided")
looks_dir = normalized.endswith(("/", "\\"))
if os.path.isdir(normalized) or looks_dir:
base_dir = normalized.rstrip("/\\") or "."
os.makedirs(base_dir, exist_ok=True)
seed_suffix = str(seed) if seed is not None else "latest"
filename = f"random_build_{seed_suffix}.json"
return os.path.join(base_dir, filename)
base_dir = os.path.dirname(normalized)
if base_dir:
os.makedirs(base_dir, exist_ok=True)
return normalized
def _resolve_random_bool(
cli_value: Optional[bool],
env_name: str,
random_section: Dict[str, Any],
json_key: str,
default: Optional[bool],
) -> Optional[bool]:
if cli_value is not None:
return bool(cli_value)
env_val = os.getenv(env_name)
result = _parse_bool(env_val) if env_val is not None else None
if result is not None:
return result
if json_key in random_section:
result = _parse_bool(random_section.get(json_key))
if result is not None:
return result
return default
def _resolve_random_str(
cli_value: Optional[str],
env_name: str,
random_section: Dict[str, Any],
json_key: str,
default: Optional[str] = None,
) -> Optional[str]:
candidates: Tuple[Any, ...] = (
cli_value,
os.getenv(env_name),
random_section.get(json_key),
default,
)
for candidate in candidates:
if candidate is None:
continue
try:
text = str(candidate).strip()
except Exception:
continue
if text:
return text
return None
def _resolve_random_int(
cli_value: Optional[int],
env_name: str,
random_section: Dict[str, Any],
json_key: str,
default: int,
) -> int:
if cli_value is not None:
try:
return int(cli_value)
except Exception:
pass
env_val = os.getenv(env_name)
if env_val is not None and str(env_val).strip() != "":
try:
return int(float(str(env_val).strip()))
except Exception:
pass
if json_key in random_section:
value = random_section.get(json_key)
try:
if isinstance(value, str):
value = value.strip()
if value:
return int(float(value))
elif value is not None:
return int(value)
except Exception:
pass
return default
def _resolve_random_seed(cli_value: Optional[str], random_section: Dict[str, Any]) -> Optional[int | str]:
seed = _try_convert_seed(cli_value)
if seed is not None:
return seed
seed = _try_convert_seed(os.getenv("RANDOM_SEED"))
if seed is not None:
return seed
return _try_convert_seed(random_section.get("seed"))
def _extract_random_section(json_cfg: Dict[str, Any]) -> Dict[str, Any]:
section = json_cfg.get("random")
if isinstance(section, dict):
return dict(section)
alt = json_cfg.get("random_config")
if isinstance(alt, dict):
return dict(alt)
return {}
def _should_run_random_mode(args: argparse.Namespace, json_cfg: Dict[str, Any], random_section: Dict[str, Any]) -> bool:
if getattr(args, "random_mode", False):
return True
if _parse_bool(os.getenv("HEADLESS_RANDOM_MODE")):
return True
if (os.getenv("DECK_MODE") or "").strip().lower() == "random":
return True
if _parse_bool(json_cfg.get("random_mode")):
return True
if _parse_bool(random_section.get("enabled")):
return True
# Detect CLI or env hints that imply random mode even without explicit flag
cli_indicators = (
getattr(args, "random_theme", None),
getattr(args, "random_primary_theme", None),
getattr(args, "random_secondary_theme", None),
getattr(args, "random_tertiary_theme", None),
getattr(args, "random_seed", None),
getattr(args, "random_auto_fill", None),
getattr(args, "random_auto_fill_secondary", None),
getattr(args, "random_auto_fill_tertiary", None),
getattr(args, "random_strict_theme_match", None),
getattr(args, "random_attempts", None),
getattr(args, "random_timeout_ms", None),
getattr(args, "random_constraints", None),
getattr(args, "random_output_json", None),
)
if any(value is not None for value in cli_indicators):
return True
for env_name in (
"RANDOM_THEME",
"RANDOM_PRIMARY_THEME",
"RANDOM_SECONDARY_THEME",
"RANDOM_TERTIARY_THEME",
"RANDOM_CONSTRAINTS",
"RANDOM_CONSTRAINTS_PATH",
"RANDOM_OUTPUT_JSON",
):
if os.getenv(env_name):
return True
noteworthy_keys = (
"theme",
"primary_theme",
"secondary_theme",
"tertiary_theme",
"seed",
"auto_fill",
"auto_fill_secondary",
"auto_fill_tertiary",
"strict_theme_match",
"attempts",
"timeout_ms",
"constraints",
"constraints_path",
"output_json",
)
if any(random_section.get(key) for key in noteworthy_keys):
return True
return False
def _resolve_random_config(args: argparse.Namespace, json_cfg: Dict[str, Any]) -> Tuple[RandomRunConfig, Dict[str, Any]]:
random_section = _extract_random_section(json_cfg)
cfg = RandomRunConfig()
cfg.legacy_theme = _resolve_random_str(
getattr(args, "random_theme", None),
"RANDOM_THEME",
random_section,
"theme",
None,
)
cfg.primary_theme = _resolve_random_str(
getattr(args, "random_primary_theme", None),
"RANDOM_PRIMARY_THEME",
random_section,
"primary_theme",
cfg.legacy_theme,
)
cfg.secondary_theme = _resolve_random_str(
getattr(args, "random_secondary_theme", None),
"RANDOM_SECONDARY_THEME",
random_section,
"secondary_theme",
None,
)
cfg.tertiary_theme = _resolve_random_str(
getattr(args, "random_tertiary_theme", None),
"RANDOM_TERTIARY_THEME",
random_section,
"tertiary_theme",
None,
)
auto_fill_flag = _resolve_random_bool(
getattr(args, "random_auto_fill", None),
"RANDOM_AUTO_FILL",
random_section,
"auto_fill",
False,
)
cfg.auto_fill_missing = bool(auto_fill_flag)
cfg.auto_fill_secondary = _resolve_random_bool(
getattr(args, "random_auto_fill_secondary", None),
"RANDOM_AUTO_FILL_SECONDARY",
random_section,
"auto_fill_secondary",
None,
)
cfg.auto_fill_tertiary = _resolve_random_bool(
getattr(args, "random_auto_fill_tertiary", None),
"RANDOM_AUTO_FILL_TERTIARY",
random_section,
"auto_fill_tertiary",
None,
)
cfg.strict_theme_match = bool(
_resolve_random_bool(
getattr(args, "random_strict_theme_match", None),
"RANDOM_STRICT_THEME_MATCH",
random_section,
"strict_theme_match",
False,
)
)
cfg.attempts = max(
1,
_resolve_random_int(
getattr(args, "random_attempts", None),
"RANDOM_MAX_ATTEMPTS",
random_section,
"attempts",
5,
),
)
cfg.timeout_ms = max(
100,
_resolve_random_int(
getattr(args, "random_timeout_ms", None),
"RANDOM_TIMEOUT_MS",
random_section,
"timeout_ms",
5000,
),
)
cfg.seed = _resolve_random_seed(getattr(args, "random_seed", None), random_section)
# Resolve constraints in precedence order: CLI > env JSON > env path > config dict > config path
constraints_candidates: Tuple[Any, ...] = (
getattr(args, "random_constraints", None),
os.getenv("RANDOM_CONSTRAINTS"),
os.getenv("RANDOM_CONSTRAINTS_PATH"),
random_section.get("constraints"),
random_section.get("constraints_path"),
)
for candidate in constraints_candidates:
loaded = _load_constraints_spec(candidate)
if loaded:
cfg.constraints = loaded
break
cfg.output_json = _resolve_random_str(
getattr(args, "random_output_json", None),
"RANDOM_OUTPUT_JSON",
random_section,
"output_json",
None,
)
if cfg.primary_theme is None:
cfg.primary_theme = cfg.legacy_theme
if cfg.primary_theme and not cfg.legacy_theme:
cfg.legacy_theme = cfg.primary_theme
if cfg.auto_fill_missing:
if cfg.auto_fill_secondary is None:
cfg.auto_fill_secondary = True
if cfg.auto_fill_tertiary is None:
cfg.auto_fill_tertiary = True
return cfg, random_section
def _print_random_summary(result: Any, config: RandomRunConfig) -> None:
print("\n" + "=" * 60)
print("RANDOM MODE BUILD")
print("=" * 60)
commander = getattr(result, "commander", None) or "(unknown)"
print(f"Commander : {commander}")
seed_value = getattr(result, "seed", config.seed)
print(f"Seed : {seed_value}")
display_themes = list(getattr(result, "display_themes", []) or [])
if not display_themes:
primary = getattr(result, "primary_theme", config.primary_theme)
if primary:
display_themes.append(primary)
for extra in (
getattr(result, "secondary_theme", config.secondary_theme),
getattr(result, "tertiary_theme", config.tertiary_theme),
):
if extra:
display_themes.append(extra)
if display_themes:
print(f"Themes : {', '.join(display_themes)}")
else:
print("Themes : (none)")
fallback_kinds: List[str] = []
if getattr(result, "combo_fallback", False):
fallback_kinds.append("combo")
if getattr(result, "synergy_fallback", False):
fallback_kinds.append("synergy")
fallback_reason = getattr(result, "fallback_reason", None)
print(f"Fallback : {('/'.join(fallback_kinds)) if fallback_kinds else 'none'}")
if fallback_reason:
print(f"Fallback reason : {fallback_reason}")
auto_secondary = getattr(result, "auto_fill_secondary_enabled", config.auto_fill_secondary or False)
auto_tertiary = getattr(result, "auto_fill_tertiary_enabled", config.auto_fill_tertiary or False)
print(
"Auto-fill : secondary={} | tertiary={}".format(
"on" if auto_secondary else "off",
"on" if auto_tertiary else "off",
)
)
print(f"Strict match : {'on' if config.strict_theme_match else 'off'}")
attempts_used = getattr(result, "attempts_tried", None)
if attempts_used is None:
attempts_used = config.attempts
print(f"Attempts used : {attempts_used} / {config.attempts}")
timeout_hit = getattr(result, "timeout_hit", False)
print(f"Timeout (ms) : {config.timeout_ms} (timeout_hit={timeout_hit})")
if config.constraints:
try:
print("Constraints :")
print(json.dumps(config.constraints, indent=2))
except Exception:
print(f"Constraints : {config.constraints}")
csv_path = getattr(result, "csv_path", None)
if csv_path:
print(f"Deck CSV : {csv_path}")
txt_path = getattr(result, "txt_path", None)
if txt_path:
print(f"Deck TXT : {txt_path}")
compliance = getattr(result, "compliance", None)
if compliance:
if isinstance(compliance, dict) and compliance.get("path"):
print(f"Compliance JSON : {compliance['path']}")
else:
try:
print("Compliance data :")
print(json.dumps(compliance, indent=2))
except Exception:
print(f"Compliance data : {compliance}")
summary = getattr(result, "summary", None)
if summary:
try:
rendered = json.dumps(summary, indent=2)
except Exception:
rendered = str(summary)
preview = rendered[:1000]
print("Summary preview :")
print(preview + ("..." if len(rendered) > len(preview) else ""))
decklist = getattr(result, "decklist", None)
if decklist:
try:
print(f"Decklist cards : {len(decklist)}")
except Exception:
pass
print("=" * 60)
def _write_random_payload(config: RandomRunConfig, result: Any) -> None:
if not config.output_json:
return
try:
path = _resolve_pathish_target(config.output_json, getattr(result, "seed", config.seed))
except Exception as exc:
print(f"Warning: unable to resolve random output path '{config.output_json}': {exc}")
return
seed_value = getattr(result, "seed", config.seed)
try:
normalized_seed = int(seed_value) if seed_value is not None else None
except Exception:
normalized_seed = seed_value
payload: Dict[str, Any] = {
"seed": normalized_seed,
"commander": getattr(result, "commander", None),
"themes": {
"primary": getattr(result, "primary_theme", config.primary_theme),
"secondary": getattr(result, "secondary_theme", config.secondary_theme),
"tertiary": getattr(result, "tertiary_theme", config.tertiary_theme),
"resolved": list(getattr(result, "resolved_themes", []) or []),
"display": list(getattr(result, "display_themes", []) or []),
"auto_filled": list(getattr(result, "auto_filled_themes", []) or []),
},
"strict_theme_match": bool(config.strict_theme_match),
"auto_fill": {
"missing": bool(config.auto_fill_missing),
"secondary": bool(getattr(result, "auto_fill_secondary_enabled", config.auto_fill_secondary or False)),
"tertiary": bool(getattr(result, "auto_fill_tertiary_enabled", config.auto_fill_tertiary or False)),
"applied": bool(getattr(result, "auto_fill_applied", False)),
},
"attempts": {
"configured": config.attempts,
"used": int(getattr(result, "attempts_tried", config.attempts) or config.attempts),
"timeout_ms": config.timeout_ms,
"timeout_hit": bool(getattr(result, "timeout_hit", False)),
"retries_exhausted": bool(getattr(result, "retries_exhausted", False)),
},
"fallback": {
"combo": bool(getattr(result, "combo_fallback", False)),
"synergy": bool(getattr(result, "synergy_fallback", False)),
"reason": getattr(result, "fallback_reason", None),
},
"constraints": config.constraints,
"csv_path": getattr(result, "csv_path", None),
"txt_path": getattr(result, "txt_path", None),
"compliance": getattr(result, "compliance", None),
"summary": getattr(result, "summary", None),
"decklist": getattr(result, "decklist", None),
}
try:
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
print(f"Random build payload written to {path}")
except Exception as exc:
print(f"Warning: failed to write random payload '{path}': {exc}")
def _run_random_mode(config: RandomRunConfig) -> int:
try:
from deck_builder.random_entrypoint import (
RandomConstraintsImpossibleError,
RandomThemeNoMatchError,
build_random_full_deck,
) # type: ignore
except Exception as exc:
print(f"Random mode unavailable: {exc}")
return 1
timeout_ms = max(100, int(config.timeout_ms))
attempts = max(1, int(config.attempts))
try:
result = build_random_full_deck(
theme=config.legacy_theme,
constraints=config.constraints or None,
seed=config.seed,
attempts=attempts,
timeout_s=float(timeout_ms) / 1000.0,
primary_theme=config.primary_theme,
secondary_theme=config.secondary_theme,
tertiary_theme=config.tertiary_theme,
auto_fill_missing=config.auto_fill_missing,
auto_fill_secondary=config.auto_fill_secondary,
auto_fill_tertiary=config.auto_fill_tertiary,
strict_theme_match=config.strict_theme_match,
)
except RandomThemeNoMatchError as exc:
print(f"Random mode failed: strict theme match produced no results ({exc})")
return 3
except RandomConstraintsImpossibleError as exc:
print(f"Random mode constraints impossible: {exc}")
return 4
except Exception as exc:
print(f"Random mode encountered an unexpected error: {exc}")
return 1
_print_random_summary(result, config)
_write_random_payload(config, result)
return 0
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)")
# Random mode configuration (parity with web random builder)
random_group = p.add_argument_group(
"Random Mode",
"Generate decks using the random web builder flow",
)
random_group.add_argument(
"--random-mode",
action="store_true",
help="Force random-mode build even if other inputs are provided",
)
random_group.add_argument(
"--random-theme",
metavar="THEME",
default=None,
help="Legacy random theme (maps to primary theme if unspecified)",
)
random_group.add_argument(
"--random-primary-theme",
metavar="THEME",
default=None,
help="Primary theme slug for random mode",
)
random_group.add_argument(
"--random-secondary-theme",
metavar="THEME",
default=None,
help="Secondary theme slug for random mode",
)
random_group.add_argument(
"--random-tertiary-theme",
metavar="THEME",
default=None,
help="Tertiary theme slug for random mode",
)
random_group.add_argument(
"--random-auto-fill",
metavar="BOOL",
type=_parse_bool,
default=None,
help="Enable auto-fill assistance for missing theme slots",
)
random_group.add_argument(
"--random-auto-fill-secondary",
metavar="BOOL",
type=_parse_bool,
default=None,
help="Enable auto-fill specifically for secondary theme",
)
random_group.add_argument(
"--random-auto-fill-tertiary",
metavar="BOOL",
type=_parse_bool,
default=None,
help="Enable auto-fill specifically for tertiary theme",
)
random_group.add_argument(
"--random-strict-theme-match",
metavar="BOOL",
type=_parse_bool,
default=None,
help="Require strict theme matches when selecting commanders",
)
random_group.add_argument(
"--random-attempts",
metavar="INT",
type=int,
default=None,
help="Maximum attempts before giving up (default 5)",
)
random_group.add_argument(
"--random-timeout-ms",
metavar="INT",
type=int,
default=None,
help="Timeout in milliseconds for theme search (default 5000)",
)
random_group.add_argument(
"--random-seed",
metavar="SEED",
default=None,
help="Seed value for deterministic random builds",
)
random_group.add_argument(
"--random-constraints",
metavar="JSON_OR_PATH",
default=None,
help="Random constraints as JSON or a path to a JSON file",
)
random_group.add_argument(
"--random-output-json",
metavar="PATH",
default=None,
help="Write random build payload JSON to PATH (directory or file)",
)
# 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
random_config, random_section = _resolve_random_config(args, json_cfg)
if _should_run_random_mode(args, json_cfg, random_section):
if args.dry_run:
print(json.dumps({"random_mode": True, "config": asdict(random_config)}, indent=2))
return 0
return _run_random_mode(random_config)
# 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())