mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
1859 lines
72 KiB
Python
1859 lines
72 KiB
Python
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 deck_builder.partner_selection import apply_partner_inputs
|
|
from deck_builder.theme_resolution import (
|
|
ThemeResolutionInfo,
|
|
clean_theme_inputs,
|
|
normalize_theme_match_mode,
|
|
parse_theme_list,
|
|
resolve_additional_theme_inputs,
|
|
)
|
|
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,
|
|
additional_themes: Optional[List[str]] = None,
|
|
theme_match_mode: str = "permissive",
|
|
user_theme_resolution: Optional[ThemeResolutionInfo] = None,
|
|
user_theme_weight: Optional[float] = None,
|
|
secondary_commander: Optional[str] = None,
|
|
background: Optional[str] = None,
|
|
enable_partner_mechanics: bool = False,
|
|
) -> DeckBuilder:
|
|
"""Run a scripted non-interactive deck build and return the DeckBuilder instance.
|
|
|
|
When ``enable_partner_mechanics`` is True, optional ``secondary_commander``
|
|
or ``background`` inputs are resolved into a combined commander pairing
|
|
before any deck-building steps execute.
|
|
"""
|
|
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
|
|
|
|
partner_feature_enabled = bool(enable_partner_mechanics)
|
|
secondary_clean = (secondary_commander or "").strip()
|
|
background_clean = (background or "").strip()
|
|
try:
|
|
builder.partner_feature_enabled = partner_feature_enabled # type: ignore[attr-defined]
|
|
builder.requested_secondary_commander = secondary_clean or None # type: ignore[attr-defined]
|
|
builder.requested_background = background_clean or None # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
|
|
if partner_feature_enabled and trimmed_commander:
|
|
combined_result = apply_partner_inputs(
|
|
builder,
|
|
primary_name=trimmed_commander,
|
|
secondary_name=secondary_clean or None,
|
|
background_name=background_clean or None,
|
|
feature_enabled=True,
|
|
)
|
|
if combined_result is not None:
|
|
_apply_combined_commander_to_builder(builder, combined_result)
|
|
|
|
# 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
|
|
|
|
normalized_theme_mode = normalize_theme_match_mode(theme_match_mode)
|
|
theme_resolution = user_theme_resolution
|
|
if theme_resolution is None:
|
|
theme_resolution = resolve_additional_theme_inputs(
|
|
additional_themes or [],
|
|
normalized_theme_mode,
|
|
)
|
|
else:
|
|
if theme_resolution.mode != normalized_theme_mode:
|
|
theme_resolution = resolve_additional_theme_inputs(
|
|
theme_resolution.requested,
|
|
normalized_theme_mode,
|
|
)
|
|
|
|
try:
|
|
builder.theme_match_mode = theme_resolution.mode # type: ignore[attr-defined]
|
|
builder.theme_catalog_version = theme_resolution.catalog_version # type: ignore[attr-defined]
|
|
builder.user_theme_requested = list(theme_resolution.requested) # type: ignore[attr-defined]
|
|
builder.user_theme_resolved = list(theme_resolution.resolved) # type: ignore[attr-defined]
|
|
builder.user_theme_matches = list(theme_resolution.matches) # type: ignore[attr-defined]
|
|
builder.user_theme_unresolved = list(theme_resolution.unresolved) # type: ignore[attr-defined]
|
|
builder.user_theme_fuzzy_corrections = dict(theme_resolution.fuzzy_corrections) # type: ignore[attr-defined]
|
|
builder.user_theme_resolution = theme_resolution # type: ignore[attr-defined]
|
|
if user_theme_weight is not None:
|
|
builder.user_theme_weight = float(user_theme_weight) # 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 _apply_combined_commander_to_builder(builder: DeckBuilder, combined_commander: Any) -> None:
|
|
"""Attach combined commander metadata to the builder for downstream use."""
|
|
|
|
try:
|
|
builder.combined_commander = combined_commander # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
builder.partner_mode = combined_commander.partner_mode # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
builder.secondary_commander = combined_commander.secondary_name # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
builder.combined_color_identity = combined_commander.color_identity # type: ignore[attr-defined]
|
|
builder.combined_theme_tags = combined_commander.theme_tags # type: ignore[attr-defined]
|
|
builder.partner_warnings = combined_commander.warnings # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
|
|
commander_dict = getattr(builder, "commander_dict", None)
|
|
if isinstance(commander_dict, dict):
|
|
try:
|
|
commander_dict["Partner Mode"] = combined_commander.partner_mode.value
|
|
commander_dict["Secondary Commander"] = combined_commander.secondary_name
|
|
except Exception:
|
|
pass
|
|
|
|
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_bool_cli(val: str) -> bool:
|
|
result = _parse_bool(val)
|
|
if result is None:
|
|
raise argparse.ArgumentTypeError(f"Expected a boolean value, received '{val}'")
|
|
return result
|
|
|
|
|
|
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("--secondary-commander", metavar="NAME", default=None,
|
|
help="Secondary commander name when using Partner/Partner With mechanics")
|
|
p.add_argument("--background", metavar="NAME", default=None,
|
|
help="Background card name when choosing a Background")
|
|
p.add_argument("--enable-partner-mechanics", metavar="BOOL", type=_parse_bool_cli, default=None,
|
|
help="Enable partner/background mechanics for this run (bool: true/false/1/0)")
|
|
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)")
|
|
|
|
theme_group = p.add_argument_group(
|
|
"Additional Themes",
|
|
"Supplement commander themes with catalog-backed user inputs",
|
|
)
|
|
theme_group.add_argument(
|
|
"--additional-themes",
|
|
metavar="THEMES",
|
|
type=parse_theme_list,
|
|
default=None,
|
|
help="Additional theme names (comma or semicolon separated)",
|
|
)
|
|
theme_group.add_argument(
|
|
"--theme-match-mode",
|
|
metavar="MODE",
|
|
choices=["strict", "permissive"],
|
|
default=None,
|
|
help="Theme resolution strategy (strict requires all matches)",
|
|
)
|
|
theme_group.add_argument(
|
|
"--user-theme-weight",
|
|
metavar="FLOAT",
|
|
type=float,
|
|
default=None,
|
|
help="Weight multiplier applied to supplemental themes (default 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 _resolve_string_option(
|
|
cli_value: Optional[str], env_name: str, json_data: Dict[str, Any], json_key: str
|
|
) -> Optional[str]:
|
|
if cli_value is not None:
|
|
text = str(cli_value).strip()
|
|
return text or None
|
|
|
|
env_val = os.getenv(env_name)
|
|
if env_val:
|
|
text = env_val.strip()
|
|
if text:
|
|
return text
|
|
|
|
raw = json_data.get(json_key)
|
|
if raw is not None:
|
|
text = str(raw).strip()
|
|
if text:
|
|
return text
|
|
return None
|
|
|
|
|
|
def _resolve_bool_option(
|
|
cli_value: Optional[bool], env_name: str, json_data: Dict[str, Any], json_key: str
|
|
) -> Optional[bool]:
|
|
if cli_value is not None:
|
|
return bool(cli_value)
|
|
|
|
env_val = os.getenv(env_name)
|
|
if env_val is not None:
|
|
parsed = _parse_bool(env_val)
|
|
if parsed is not None:
|
|
return parsed
|
|
|
|
raw = json_data.get(json_key)
|
|
if raw is not None:
|
|
if isinstance(raw, bool):
|
|
return raw
|
|
parsed = _parse_bool(str(raw))
|
|
if parsed is not None:
|
|
return parsed
|
|
return None
|
|
|
|
|
|
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
|
|
primary_tag_name: Optional[str] = None
|
|
secondary_tag_name: Optional[str] = None
|
|
tertiary_tag_name: Optional[str] = None
|
|
|
|
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
|
|
|
|
additional_themes_json: List[str] = []
|
|
try:
|
|
collected: List[str] = []
|
|
for key in ("additional_themes", "userThemes"):
|
|
raw_value = json_cfg.get(key)
|
|
if isinstance(raw_value, list):
|
|
collected.extend(raw_value)
|
|
if collected:
|
|
additional_themes_json = clean_theme_inputs(collected)
|
|
except Exception:
|
|
additional_themes_json = []
|
|
|
|
cli_additional_themes: List[str] = []
|
|
if hasattr(args, "additional_themes") and args.additional_themes:
|
|
if isinstance(args.additional_themes, list):
|
|
cli_additional_themes = clean_theme_inputs(args.additional_themes)
|
|
else:
|
|
cli_additional_themes = parse_theme_list(str(args.additional_themes))
|
|
|
|
env_additional_themes = parse_theme_list(os.getenv("DECK_ADDITIONAL_THEMES"))
|
|
|
|
additional_theme_inputs = (
|
|
cli_additional_themes
|
|
or env_additional_themes
|
|
or additional_themes_json
|
|
)
|
|
|
|
theme_mode_value = getattr(args, "theme_match_mode", None)
|
|
if not theme_mode_value:
|
|
theme_mode_value = os.getenv("THEME_MATCH_MODE")
|
|
if not theme_mode_value:
|
|
theme_mode_value = json_cfg.get("theme_match_mode") or json_cfg.get("themeMatchMode")
|
|
normalized_theme_mode = normalize_theme_match_mode(theme_mode_value)
|
|
|
|
weight_value: Optional[float]
|
|
if hasattr(args, "user_theme_weight") and args.user_theme_weight is not None:
|
|
weight_value = args.user_theme_weight
|
|
else:
|
|
cfg_weight = json_cfg.get("user_theme_weight")
|
|
if cfg_weight is not None:
|
|
try:
|
|
weight_value = float(cfg_weight)
|
|
except Exception:
|
|
weight_value = None
|
|
else:
|
|
weight_value = None
|
|
|
|
commander_tag_names = [
|
|
str(tag)
|
|
for tag in (primary_tag_name, secondary_tag_name, tertiary_tag_name)
|
|
if isinstance(tag, str) and tag and str(tag).strip()
|
|
]
|
|
|
|
try:
|
|
theme_resolution = resolve_additional_theme_inputs(
|
|
additional_theme_inputs,
|
|
normalized_theme_mode,
|
|
commander_tags=commander_tag_names,
|
|
)
|
|
except ValueError as exc:
|
|
print(str(exc))
|
|
return 2
|
|
|
|
resolved_secondary_commander = _resolve_string_option(
|
|
getattr(args, "secondary_commander", None),
|
|
"DECK_SECONDARY_COMMANDER",
|
|
json_cfg,
|
|
"secondary_commander",
|
|
)
|
|
resolved_background = _resolve_string_option(
|
|
getattr(args, "background", None),
|
|
"DECK_BACKGROUND",
|
|
json_cfg,
|
|
"background",
|
|
)
|
|
resolved_partner_flag = _resolve_bool_option(
|
|
getattr(args, "enable_partner_mechanics", None),
|
|
"ENABLE_PARTNER_MECHANICS",
|
|
json_cfg,
|
|
"enable_partner_mechanics",
|
|
)
|
|
|
|
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)),
|
|
"additional_themes": list(theme_resolution.requested),
|
|
"theme_match_mode": theme_resolution.mode,
|
|
"user_theme_weight": weight_value,
|
|
"secondary_commander": resolved_secondary_commander,
|
|
"background": resolved_background,
|
|
"enable_partner_mechanics": bool(resolved_partner_flag) if resolved_partner_flag is not None else False,
|
|
}
|
|
|
|
if args.dry_run:
|
|
preview = dict(resolved)
|
|
preview["additional_themes_resolved"] = list(theme_resolution.resolved)
|
|
preview["additional_themes_unresolved"] = list(theme_resolution.unresolved)
|
|
preview["theme_catalog_version"] = theme_resolution.catalog_version
|
|
preview["fuzzy_corrections"] = dict(theme_resolution.fuzzy_corrections)
|
|
preview["user_theme_weight"] = weight_value
|
|
print(json.dumps(preview, 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
|
|
|
|
if theme_resolution.requested:
|
|
if theme_resolution.fuzzy_corrections:
|
|
print("Fuzzy theme corrections applied:")
|
|
for original, corrected in theme_resolution.fuzzy_corrections.items():
|
|
print(f" • {original} → {corrected}")
|
|
if theme_resolution.unresolved and theme_resolution.mode != "strict":
|
|
print("Warning: unresolved additional themes (permissive mode):")
|
|
for item in theme_resolution.unresolved:
|
|
suggestion_text = ", ".join(
|
|
f"{s['theme']} ({s['score']:.1f})" for s in item.get("suggestions", [])
|
|
)
|
|
if suggestion_text:
|
|
print(f" • {item['input']} → suggestions: {suggestion_text}")
|
|
else:
|
|
print(f" • {item['input']} (no suggestions)")
|
|
|
|
try:
|
|
run_kwargs = dict(resolved)
|
|
run_kwargs["user_theme_resolution"] = theme_resolution
|
|
run_kwargs["enable_partner_mechanics"] = bool(resolved_partner_flag)
|
|
run(**run_kwargs)
|
|
except CommanderValidationError as exc:
|
|
print(str(exc))
|
|
return 2
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(_main())
|