mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
405 lines
17 KiB
Python
405 lines
17 KiB
Python
![]() |
from __future__ import annotations
|
||
|
|
||
|
import argparse
|
||
|
import json
|
||
|
import os
|
||
|
from typing import Any, Dict, List, Optional
|
||
|
from pathlib import Path
|
||
|
|
||
|
from code.deck_builder.builder import DeckBuilder
|
||
|
|
||
|
"""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')
|
||
|
if hasattr(builder, 'export_run_config_json'):
|
||
|
try:
|
||
|
cfg_path_env = os.getenv('DECK_CONFIG')
|
||
|
if cfg_path_env:
|
||
|
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')
|
||
|
# If an explicit DECK_CONFIG path is given, also write to exactly that path
|
||
|
if cfg_path_env:
|
||
|
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("\nAvailable 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())
|