2025-08-22 16:32:39 -07:00
from __future__ import annotations
import argparse
import json
import os
from typing import Any , Dict , List , Optional
from pathlib import Path
2025-08-22 16:46:44 -07:00
from deck_builder . builder import DeckBuilder
2025-08-22 16:32:39 -07:00
""" Headless (non-interactive) runner.
Features :
- Script commander selection .
- Script primary / optional secondary / tertiary tags .
- Apply bracket & accept default ideal counts .
- Invoke multi - theme creature addition if available ( fallback to primary - only ) .
Use run ( . . . , secondary_choice = 2 , tertiary_choice = 3 , use_multi_theme = True ) to exercise multi - theme logic .
Indices correspond to the numbered tag list presented during interaction .
"""
def run (
command_name : str = " Pantlaza " ,
add_creatures : bool = True ,
add_non_creature_spells : bool = True ,
# Fine-grained toggles (used only if add_non_creature_spells is False)
add_ramp : bool = True ,
add_removal : bool = True ,
add_wipes : bool = True ,
add_card_advantage : bool = True ,
add_protection : bool = True ,
use_multi_theme : bool = True ,
primary_choice : int = 2 ,
secondary_choice : Optional [ int ] = 2 ,
tertiary_choice : Optional [ int ] = 2 ,
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 ,
) - > DeckBuilder :
""" Run a scripted non-interactive deck build and return the DeckBuilder instance.
Integer parameters ( primary_choice , secondary_choice , tertiary_choice ) correspond to the
numeric indices shown during interactive tag selection . Pass None to omit secondary / tertiary .
Optional counts ( fetch_count , dual_count , triple_count , utility_count ) constrain land steps .
"""
scripted_inputs : List [ str ] = [ ]
# Commander query & selection
scripted_inputs . append ( command_name ) # initial query
scripted_inputs . append ( " 1 " ) # choose first search match to inspect
scripted_inputs . append ( " y " ) # confirm commander
# Primary tag selection
scripted_inputs . append ( str ( primary_choice ) )
# Secondary tag selection or stop (0)
if secondary_choice is not None :
scripted_inputs . append ( str ( secondary_choice ) )
# Tertiary tag selection or stop (0)
if tertiary_choice is not None :
scripted_inputs . append ( str ( tertiary_choice ) )
else :
scripted_inputs . append ( " 0 " )
else :
scripted_inputs . append ( " 0 " ) # stop at primary
# Bracket (meta power / style) selection; keeping existing scripted value
scripted_inputs . append ( " 3 " )
# Ideal count prompts (press Enter for defaults)
for _ in range ( 8 ) :
scripted_inputs . append ( " " )
def scripted_input ( prompt : str ) - > str :
if scripted_inputs :
return scripted_inputs . pop ( 0 )
raise RuntimeError ( " Ran out of scripted inputs for prompt: " + prompt )
builder = DeckBuilder ( input_func = scripted_input )
# Mark this run as headless so builder can adjust exports and logging
try :
builder . headless = True # 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 :
if hasattr ( builder , ' run_land_step1 ' ) :
builder . run_land_step1 ( ) # Basics / initial
if hasattr ( builder , ' run_land_step2 ' ) :
builder . run_land_step2 ( ) # Utility basics / rebalancing
if hasattr ( builder , ' run_land_step3 ' ) :
builder . run_land_step3 ( ) # Kindred lands if applicable
if hasattr ( builder , ' run_land_step4 ' ) :
builder . run_land_step4 ( requested_count = fetch_count )
if hasattr ( builder , ' run_land_step5 ' ) :
builder . run_land_step5 ( requested_count = dual_count )
if hasattr ( builder , ' run_land_step6 ' ) :
builder . run_land_step6 ( requested_count = triple_count )
if hasattr ( builder , ' run_land_step7 ' ) :
builder . run_land_step7 ( requested_count = utility_count )
if hasattr ( builder , ' run_land_step8 ' ) :
builder . run_land_step8 ( )
if add_creatures :
builder . add_creatures ( )
# Non-creature spell categories (ramp / removal / wipes / draw / protection)
if add_non_creature_spells and hasattr ( builder , ' add_non_creature_spells ' ) :
builder . add_non_creature_spells ( )
else :
# Allow selective invocation if orchestrator not desired
if add_ramp and hasattr ( builder , ' add_ramp ' ) :
builder . add_ramp ( )
if add_removal and hasattr ( builder , ' add_removal ' ) :
builder . add_removal ( )
if add_wipes and hasattr ( builder , ' add_board_wipes ' ) :
builder . add_board_wipes ( )
if add_card_advantage and hasattr ( builder , ' add_card_advantage ' ) :
builder . add_card_advantage ( )
if add_protection and hasattr ( builder , ' add_protection ' ) :
builder . add_protection ( )
# Suppress verbose library print in headless run since CSV export is produced.
# builder.print_card_library()
builder . post_spell_land_adjust ( )
# Export decklist CSV (commander first word + date)
csv_path : Optional [ str ] = None
if hasattr ( builder , ' export_decklist_csv ' ) :
try :
csv_path = builder . export_decklist_csv ( )
except Exception :
csv_path = None
if hasattr ( builder , ' export_decklist_text ' ) :
try :
if csv_path :
base = os . path . splitext ( os . path . basename ( csv_path ) ) [ 0 ]
builder . export_decklist_text ( filename = base + ' .txt ' )
2025-08-22 16:46:44 -07:00
# Headless policy: do NOT export JSON by default. Opt-in with HEADLESS_EXPORT_JSON=1
allow_json = ( os . getenv ( ' HEADLESS_EXPORT_JSON ' , ' ' ) . strip ( ) . lower ( ) in { ' 1 ' , ' true ' , ' yes ' , ' on ' } )
if allow_json and hasattr ( builder , ' export_run_config_json ' ) :
2025-08-22 16:32:39 -07:00
try :
cfg_path_env = os . getenv ( ' DECK_CONFIG ' )
2025-08-22 16:46:44 -07:00
if cfg_path_env and os . path . isdir ( os . path . dirname ( cfg_path_env ) or ' . ' ) :
2025-08-22 16:32:39 -07:00
cfg_dir = os . path . dirname ( cfg_path_env ) or ' . '
elif os . path . isdir ( ' /app/config ' ) :
cfg_dir = ' /app/config '
else :
cfg_dir = ' config '
os . makedirs ( cfg_dir , exist_ok = True )
builder . export_run_config_json ( directory = cfg_dir , filename = base + ' .json ' )
2025-08-22 16:46:44 -07:00
# If an explicit DECK_CONFIG path is given to a file, write exactly there as well
if cfg_path_env and os . path . splitext ( cfg_path_env ) [ 1 ] . lower ( ) == ' .json ' :
2025-08-22 16:32:39 -07:00
cfg_dir2 = os . path . dirname ( cfg_path_env ) or ' . '
cfg_name2 = os . path . basename ( cfg_path_env )
os . makedirs ( cfg_dir2 , exist_ok = True )
builder . export_run_config_json ( directory = cfg_dir2 , filename = cfg_name2 )
except Exception :
pass
else :
builder . export_decklist_text ( )
except Exception :
pass
return builder
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_opt_int ( val : Optional [ str | int ] ) - > Optional [ int ] :
if val is None :
return None
if isinstance ( val , int ) :
return val
s = str ( val ) . strip ( ) . lower ( )
if s in { " " , " none " , " null " , " nan " } :
return None
return int ( s )
def _load_json_config ( path : Optional [ str ] ) - > Dict [ str , Any ] :
if not path :
return { }
try :
with open ( path , " r " , encoding = " utf-8 " ) as f :
data = json . load ( f )
if not isinstance ( data , dict ) :
raise ValueError ( " JSON config must be an object " )
return data
except FileNotFoundError :
raise
def _build_arg_parser ( ) - > argparse . ArgumentParser :
p = argparse . ArgumentParser ( description = " Headless deck builder runner " )
p . add_argument ( " --config " , default = os . getenv ( " DECK_CONFIG " ) , help = " Path to JSON config file " )
p . add_argument ( " --commander " , default = None )
p . add_argument ( " --primary-choice " , type = int , default = None )
p . add_argument ( " --secondary-choice " , type = _parse_opt_int , default = None )
p . add_argument ( " --tertiary-choice " , type = _parse_opt_int , default = None )
p . add_argument ( " --add-lands " , type = _parse_bool , default = None )
p . add_argument ( " --fetch-count " , type = _parse_opt_int , default = None )
p . add_argument ( " --dual-count " , type = _parse_opt_int , default = None )
p . add_argument ( " --triple-count " , type = _parse_opt_int , default = None )
p . add_argument ( " --utility-count " , type = _parse_opt_int , default = None )
# no seed support
# Booleans
p . add_argument ( " --add-creatures " , type = _parse_bool , default = None )
p . add_argument ( " --add-non-creature-spells " , type = _parse_bool , default = None )
p . add_argument ( " --add-ramp " , type = _parse_bool , default = None )
p . add_argument ( " --add-removal " , type = _parse_bool , default = None )
p . add_argument ( " --add-wipes " , type = _parse_bool , default = None )
p . add_argument ( " --add-card-advantage " , type = _parse_bool , default = None )
p . add_argument ( " --add-protection " , type = _parse_bool , default = None )
p . add_argument ( " --use-multi-theme " , type = _parse_bool , default = None )
p . add_argument ( " --dry-run " , action = " store_true " , help = " Print resolved config and exit " )
p . add_argument ( " --auto-select-config " , action = " store_true " , help = " If set, and multiple JSON configs exist, list and prompt to choose one before running. " )
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 :
parser = _build_arg_parser ( )
args = parser . parse_args ( )
# Optional config auto-discovery/prompting
cfg_path = args . config
json_cfg : Dict [ str , Any ] = { }
def _discover_json_configs ( ) - > List [ str ] :
# Determine directory to scan for JSON configs
if cfg_path and os . path . isdir ( cfg_path ) :
cfg_dir = cfg_path
elif os . path . isdir ( ' /app/config ' ) :
cfg_dir = ' /app/config '
else :
cfg_dir = ' config '
try :
p = Path ( cfg_dir )
return sorted ( [ str ( fp ) for fp in p . glob ( ' *.json ' ) ] ) if p . exists ( ) else [ ]
except Exception :
return [ ]
# If a file path is provided, load it directly
if cfg_path and os . path . isfile ( cfg_path ) :
json_cfg = _load_json_config ( cfg_path )
else :
# If auto-select is requested, we may prompt user to choose a config
configs = _discover_json_configs ( )
if cfg_path and os . path . isdir ( cfg_path ) :
# Directory explicitly provided, prefer auto selection behavior
if len ( configs ) == 1 :
json_cfg = _load_json_config ( configs [ 0 ] )
os . environ [ ' DECK_CONFIG ' ] = configs [ 0 ]
elif len ( configs ) > 1 and args . auto_select_config :
def _label ( p : str ) - > str :
try :
with open ( p , ' r ' , encoding = ' utf-8 ' ) as fh :
data = json . load ( fh )
cmd = str ( data . get ( ' commander ' ) or ' ' ) . strip ( ) or ' Unknown Commander '
themes = [ t for t in [ data . get ( ' primary_tag ' ) , data . get ( ' secondary_tag ' ) , data . get ( ' tertiary_tag ' ) ] if isinstance ( t , str ) and t . strip ( ) ]
return f " { cmd } - { ' , ' . join ( themes ) } " if themes else cmd
except Exception :
return p
print ( " \n Available JSON configs: " )
for idx , f in enumerate ( configs , start = 1 ) :
print ( f " { idx } ) { _label ( f ) } " )
print ( " 0) Cancel " )
while True :
try :
sel = input ( " Select a config to run [0]: " ) . strip ( ) or ' 0 '
except KeyboardInterrupt :
print ( " " )
sel = ' 0 '
if sel == ' 0 ' :
return 0
try :
i = int ( sel )
if 1 < = i < = len ( configs ) :
chosen = configs [ i - 1 ]
json_cfg = _load_json_config ( chosen )
os . environ [ ' DECK_CONFIG ' ] = chosen
break
except ValueError :
pass
print ( " Invalid selection. Try again. " )
else :
# No explicit file; if exactly one config exists, auto use it; else leave empty
if len ( configs ) == 1 :
json_cfg = _load_json_config ( configs [ 0 ] )
os . environ [ ' DECK_CONFIG ' ] = configs [ 0 ]
# Defaults mirror run() signature
defaults = dict (
command_name = " Pantlaza " ,
add_creatures = True ,
add_non_creature_spells = True ,
add_ramp = True ,
add_removal = True ,
add_wipes = True ,
add_card_advantage = True ,
add_protection = True ,
use_multi_theme = True ,
primary_choice = 2 ,
secondary_choice = 2 ,
tertiary_choice = 2 ,
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 = { }
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 " ] ) ,
" use_multi_theme " : _resolve_value ( args . use_multi_theme , " DECK_USE_MULTI_THEME " , json_cfg , " use_multi_theme " , defaults [ " use_multi_theme " ] ) ,
" primary_choice " : _resolve_value ( args . primary_choice , " DECK_PRIMARY_CHOICE " , json_cfg , " primary_choice " , defaults [ " primary_choice " ] ) ,
" secondary_choice " : _resolve_value ( args . secondary_choice , " DECK_SECONDARY_CHOICE " , json_cfg , " secondary_choice " , defaults [ " secondary_choice " ] ) ,
" tertiary_choice " : _resolve_value ( args . tertiary_choice , " DECK_TERTIARY_CHOICE " , json_cfg , " tertiary_choice " , defaults [ " tertiary_choice " ] ) ,
" 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_json ,
}
if args . dry_run :
print ( json . dumps ( resolved , indent = 2 ) )
return 0
run ( * * resolved )
return 0
if __name__ == " __main__ " :
raise SystemExit ( _main ( ) )