2025-08-22 16:32:39 -07:00
from __future__ import annotations
import argparse
import json
import os
2025-10-02 17:09:07 -07:00
import re
2025-09-29 23:00:57 -07:00
from dataclasses import asdict , dataclass , field
2025-10-02 17:09:07 -07:00
from functools import lru_cache
2025-09-29 23:00:57 -07:00
from typing import Any , Dict , List , Optional , Tuple
2025-08-22 16:32:39 -07:00
2025-08-22 16:46:44 -07:00
from deck_builder . builder import DeckBuilder
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
from deck_builder import builder_constants as bc
2025-09-05 12:46:49 -07:00
from file_setup . setup import initial_setup
from tagging import tagger
2025-10-02 17:09:07 -07:00
from exceptions import CommanderValidationError
2025-09-05 12:46:49 -07:00
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 )
2025-08-22 16:32:39 -07:00
2025-09-28 18:30:45 -07:00
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 )
2025-09-29 23:00:57 -07:00
2025-10-02 17:09:07 -07:00
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 } )
2025-09-29 23:00:57 -07:00
@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
2025-08-22 16:32:39 -07:00
def run (
2025-08-23 15:29:45 -07:00
command_name : str = " " ,
2025-08-22 16:32:39 -07:00
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 ,
2025-08-23 15:29:45 -07:00
primary_choice : int = 1 ,
secondary_choice : Optional [ int ] = None ,
tertiary_choice : Optional [ int ] = None ,
2025-08-22 16:32:39 -07:00
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 ,
2025-08-23 15:29:45 -07:00
bracket_level : Optional [ int ] = None ,
2025-09-09 09:36:17 -07:00
# 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 ,
2025-09-17 13:23:27 -07:00
seed : Optional [ int | str ] = None ,
2025-08-22 16:32:39 -07:00
) - > DeckBuilder :
2025-08-23 15:29:45 -07:00
""" Run a scripted non-interactive deck build and return the DeckBuilder instance. """
2025-10-02 17:09:07 -07:00
trimmed_commander = ( command_name or " " ) . strip ( )
if trimmed_commander :
_validate_commander_available ( trimmed_commander )
2025-09-28 18:30:45 -07:00
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 " )
2025-08-22 16:32:39 -07:00
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
2025-09-28 18:30:45 -07:00
scripted_inputs . extend ( owned_prompt_inputs )
2025-08-23 15:29:45 -07:00
# 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 ) )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
# 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 ( " " )
2025-08-22 16:32:39 -07:00
def scripted_input ( prompt : str ) - > str :
if scripted_inputs :
return scripted_inputs . pop ( 0 )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
# Fallback to auto-accept defaults for any unexpected prompts
return " "
2025-08-22 16:32:39 -07:00
builder = DeckBuilder ( input_func = scripted_input )
2025-09-17 13:23:27 -07:00
# 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
2025-08-22 16:32:39 -07:00
# Mark this run as headless so builder can adjust exports and logging
try :
builder . headless = True # type: ignore[attr-defined]
except Exception :
pass
2025-09-09 09:36:17 -07:00
# 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
2025-08-22 16:32:39 -07:00
# 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 :
2025-08-23 15:29:45 -07:00
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 )
2025-08-22 16:32:39 -07:00
if add_creatures :
builder . add_creatures ( )
# Non-creature spell categories (ramp / removal / wipes / draw / protection)
2025-08-23 15:29:45 -07:00
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
2025-08-22 16:32:39 -07:00
builder . post_spell_land_adjust ( )
2025-08-23 15:29:45 -07:00
_export_outputs ( builder )
return builder
def _should_export_json_headless ( ) - > bool :
return os . getenv ( ' HEADLESS_EXPORT_JSON ' , ' ' ) . strip ( ) . lower ( ) in { ' 1 ' , ' true ' , ' yes ' , ' on ' }
2025-09-09 18:52:47 -07:00
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 )
2025-08-23 15:29:45 -07:00
def _export_outputs ( builder : DeckBuilder ) - > None :
2025-09-09 18:52:47 -07:00
# M4: Print include/exclude summary to console
_print_include_exclude_summary ( builder )
2025-08-22 16:32:39 -07:00
csv_path : Optional [ str ] = None
2025-08-23 15:29:45 -07:00
try :
csv_path = builder . export_decklist_csv ( ) if hasattr ( builder , " export_decklist_csv " ) else None
2025-09-25 15:14:15 -07:00
# 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
2025-08-23 15:29:45 -07:00
except Exception :
csv_path = None
try :
if hasattr ( builder , " export_decklist_text " ) :
2025-08-22 16:32:39 -07:00
if csv_path :
base = os . path . splitext ( os . path . basename ( csv_path ) ) [ 0 ]
2025-09-25 15:14:15 -07:00
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
2025-08-22 16:32:39 -07:00
else :
2025-09-25 15:14:15 -07:00
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
2025-08-23 15:29:45 -07:00
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 " )
2025-08-22 16:32:39 -07:00
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
2025-09-09 18:52:47 -07:00
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 ( ) ]
2025-08-22 16:32:39 -07:00
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
2025-09-29 23:00:57 -07:00
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
2025-08-22 16:32:39 -07:00
def _build_arg_parser ( ) - > argparse . ArgumentParser :
p = argparse . ArgumentParser ( description = " Headless deck builder runner " )
2025-09-09 18:52:47 -07:00
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) " )
2025-09-29 23:00:57 -07:00
# 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) " ,
)
2025-09-09 18:52:47 -07:00
# Utility
p . add_argument ( " --dry-run " , action = " store_true " ,
help = " Print resolved configuration and exit without building " )
2025-08-22 16:32:39 -07:00
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 :
2025-09-05 12:46:49 -07:00
_ensure_data_ready ( )
2025-08-22 16:32:39 -07:00
parser = _build_arg_parser ( )
args = parser . parse_args ( )
2025-08-23 15:29:45 -07:00
# Optional config discovery (no prompts)
2025-08-22 16:32:39 -07:00
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 :
2025-08-23 15:29:45 -07:00
# 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
2025-08-22 16:32:39 -07:00
2025-09-29 23:00:57 -07:00
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 )
2025-08-22 16:32:39 -07:00
# Defaults mirror run() signature
defaults = dict (
2025-08-23 15:29:45 -07:00
command_name = " " ,
2025-08-22 16:32:39 -07:00
add_creatures = True ,
add_non_creature_spells = True ,
add_ramp = True ,
add_removal = True ,
add_wipes = True ,
add_card_advantage = True ,
add_protection = True ,
2025-08-23 15:29:45 -07:00
primary_choice = 1 ,
secondary_choice = None ,
tertiary_choice = None ,
2025-08-22 16:32:39 -07:00
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 = { }
2025-09-09 18:52:47 -07:00
# 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
2025-09-09 09:36:17 -07:00
# 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
2025-09-09 18:52:47 -07:00
# 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
2025-08-22 16:32:39 -07:00
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 " ] ) ,
2025-09-09 18:52:47 -07:00
" 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 " ] ) ,
2025-09-09 09:36:17 -07:00
" bracket_level " : _resolve_value ( args . bracket_level , " DECK_BRACKET_LEVEL " , json_cfg , " bracket_level " , None ) ,
2025-08-22 16:32:39 -07:00
" 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 " ] ) ,
2025-09-09 09:36:17 -07:00
" 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 " ] ) ,
2025-09-09 18:52:47 -07:00
" 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 ) ) ,
2025-08-22 16:32:39 -07:00
}
if args . dry_run :
print ( json . dumps ( resolved , indent = 2 ) )
return 0
2025-08-23 15:29:45 -07:00
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
2025-08-22 16:32:39 -07:00
run ( * * resolved )
return 0
if __name__ == " __main__ " :
raise SystemExit ( _main ( ) )