from __future__ import annotations import argparse import json import os from typing import Any, Dict, List, Optional import time import sys import os from deck_builder.builder import DeckBuilder from deck_builder import builder_constants as bc from file_setup.setup import initial_setup from tagging import tagger def _is_stale(file1: str, file2: str) -> bool: """Return True if file2 is missing or older than file1.""" if not os.path.isfile(file2): return True if not os.path.isfile(file1): return True return os.path.getmtime(file2) < os.path.getmtime(file1) def _ensure_data_ready(): cards_csv = os.path.join("csv_files", "cards.csv") tagging_json = os.path.join("csv_files", ".tagging_complete.json") # If cards.csv is missing, run full setup+tagging if not os.path.isfile(cards_csv): print("cards.csv not found, running full setup and tagging...") initial_setup() tagger.run_tagging() _write_tagging_flag(tagging_json) # If tagging_complete is missing or stale, run tagging elif not os.path.isfile(tagging_json) or _is_stale(cards_csv, tagging_json): print(".tagging_complete.json missing or stale, running tagging...") tagger.run_tagging() _write_tagging_flag(tagging_json) def _write_tagging_flag(tagging_json): import json from datetime import datetime os.makedirs(os.path.dirname(tagging_json), exist_ok=True) with open(tagging_json, 'w', encoding='utf-8') as f: json.dump({'tagged_at': datetime.now().isoformat(timespec='seconds')}, f) def run( command_name: str = "", add_creatures: bool = True, add_non_creature_spells: bool = True, add_ramp: bool = True, add_removal: bool = True, add_wipes: bool = True, add_card_advantage: bool = True, add_protection: bool = True, primary_choice: int = 1, secondary_choice: Optional[int] = None, tertiary_choice: Optional[int] = None, add_lands: bool = True, fetch_count: Optional[int] = 3, dual_count: Optional[int] = None, triple_count: Optional[int] = None, utility_count: Optional[int] = None, ideal_counts: Optional[Dict[str, int]] = None, bracket_level: Optional[int] = None, # Include/Exclude configuration (M1: Config + Validation + Persistence) include_cards: Optional[List[str]] = None, exclude_cards: Optional[List[str]] = None, enforcement_mode: str = "warn", allow_illegal: bool = False, fuzzy_matching: bool = True, ) -> DeckBuilder: """Run a scripted non-interactive deck build and return the DeckBuilder instance.""" 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; default to 3 if not provided scripted_inputs.append(str(bracket_level if isinstance(bracket_level, int) and 1 <= bracket_level <= 5 else 3)) # Ideal count prompts (press Enter for defaults). Include fetch_lands if present. ideal_keys = { "ramp", "lands", "basic_lands", "fetch_lands", "creatures", "removal", "wipes", "card_advantage", "protection", } for key in bc.DECK_COMPOSITION_PROMPTS.keys(): if key in ideal_keys: scripted_inputs.append("") def scripted_input(prompt: str) -> str: if scripted_inputs: return scripted_inputs.pop(0) # Fallback to auto-accept defaults for any unexpected prompts return "" builder = DeckBuilder(input_func=scripted_input) # Mark this run as headless so builder can adjust exports and logging try: builder.headless = True # type: ignore[attr-defined] except Exception: pass # Configure include/exclude settings (M1: Config + Validation + Persistence) try: builder.include_cards = list(include_cards or []) # type: ignore[attr-defined] builder.exclude_cards = list(exclude_cards or []) # type: ignore[attr-defined] builder.enforcement_mode = enforcement_mode # type: ignore[attr-defined] builder.allow_illegal = allow_illegal # type: ignore[attr-defined] builder.fuzzy_matching = fuzzy_matching # type: ignore[attr-defined] except Exception: pass # If ideal_counts are provided (from JSON), use them as the current defaults # so the step 2 prompts will show these values and our blank entries will accept them. if isinstance(ideal_counts, dict) and ideal_counts: try: ic: Dict[str, int] = {} for k, v in ideal_counts.items(): try: iv = int(v) if v is not None else None # type: ignore except Exception: continue if iv is None: continue # Only accept known keys if k in {"ramp","lands","basic_lands","creatures","removal","wipes","card_advantage","protection"}: ic[k] = iv if ic: builder.ideal_counts.update(ic) # type: ignore[attr-defined] except Exception: pass builder.run_initial_setup() builder.run_deck_build_step1() builder.run_deck_build_step2() # Land sequence (optional) if add_lands: def call(method: str, **kwargs: Any) -> None: fn = getattr(builder, method, None) if callable(fn): try: fn(**kwargs) except Exception: pass for method, kwargs in [ ("run_land_step1", {}), ("run_land_step2", {}), ("run_land_step3", {}), ("run_land_step4", {"requested_count": fetch_count}), ("run_land_step5", {"requested_count": dual_count}), ("run_land_step6", {"requested_count": triple_count}), ("run_land_step7", {"requested_count": utility_count}), ("run_land_step8", {}), ]: call(method, **kwargs) if add_creatures: builder.add_creatures() # Non-creature spell categories (ramp / removal / wipes / draw / protection) did_bulk = False if add_non_creature_spells and hasattr(builder, "add_non_creature_spells"): try: builder.add_non_creature_spells() did_bulk = True except Exception: did_bulk = False if not did_bulk: for method, flag in [ ("add_ramp", add_ramp), ("add_removal", add_removal), ("add_board_wipes", add_wipes), ("add_card_advantage", add_card_advantage), ("add_protection", add_protection), ]: if flag: fn = getattr(builder, method, None) if callable(fn): try: fn() except Exception: pass builder.post_spell_land_adjust() _export_outputs(builder) return builder def _should_export_json_headless() -> bool: return os.getenv('HEADLESS_EXPORT_JSON', '').strip().lower() in {'1','true','yes','on'} def _export_outputs(builder: DeckBuilder) -> None: csv_path: Optional[str] = None try: csv_path = builder.export_decklist_csv() if hasattr(builder, "export_decklist_csv") else None except Exception: csv_path = None try: if hasattr(builder, "export_decklist_text"): if csv_path: base = os.path.splitext(os.path.basename(csv_path))[0] builder.export_decklist_text(filename=base + ".txt") else: builder.export_decklist_text() except Exception: pass if _should_export_json_headless() and hasattr(builder, "export_run_config_json") and csv_path: try: base = os.path.splitext(os.path.basename(csv_path))[0] dest = os.getenv("DECK_CONFIG") if dest and dest.lower().endswith(".json"): out_dir, out_name = os.path.dirname(dest) or ".", os.path.basename(dest) os.makedirs(out_dir, exist_ok=True) builder.export_run_config_json(directory=out_dir, filename=out_name) else: out_dir = (dest if dest and os.path.isdir(dest) else "config") os.makedirs(out_dir, exist_ok=True) builder.export_run_config_json(directory=out_dir, filename=base + ".json") except Exception: pass def _parse_bool(val: Optional[str | bool | int]) -> Optional[bool]: if val is None: return None if isinstance(val, bool): return val if isinstance(val, int): return bool(val) s = str(val).strip().lower() if s in {"1", "true", "t", "yes", "y", "on"}: return True if s in {"0", "false", "f", "no", "n", "off"}: return False return None def _parse_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("--bracket-level", type=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("--dry-run", action="store_true", help="Print resolved config and exit") return p def _resolve_value( cli: Optional[Any], env_name: str, json_data: Dict[str, Any], json_key: str, default: Any ) -> Any: if cli is not None: return cli env_val = os.getenv(env_name) if env_val is not None: # Convert types based on default type if isinstance(default, bool): b = _parse_bool(env_val) return default if b is None else b if isinstance(default, int) or default is None: # allow optional ints try: return _parse_opt_int(env_val) except ValueError: return default return env_val if json_key in json_data: return json_data[json_key] return default def _main() -> int: _ensure_data_ready() parser = _build_arg_parser() args = parser.parse_args() # Optional config discovery (no prompts) cfg_path = args.config json_cfg: Dict[str, Any] = {} if cfg_path and os.path.isfile(cfg_path): json_cfg = _load_json_config(cfg_path) else: # No explicit file; if exactly one config exists in a known dir, use it for candidate_dir in [cfg_path] if cfg_path and os.path.isdir(cfg_path) else ["/app/config", "config"]: try: files = [f for f in (os.listdir(candidate_dir) if os.path.isdir(candidate_dir) else []) if f.lower().endswith(".json")] except Exception: files = [] if len(files) == 1: chosen = os.path.join(candidate_dir, files[0]) json_cfg = _load_json_config(chosen) os.environ["DECK_CONFIG"] = chosen break # Defaults mirror run() signature defaults = dict( command_name="", add_creatures=True, add_non_creature_spells=True, add_ramp=True, add_removal=True, add_wipes=True, add_card_advantage=True, add_protection=True, primary_choice=1, secondary_choice=None, tertiary_choice=None, add_lands=True, fetch_count=3, dual_count=None, triple_count=None, utility_count=None, ) # Pull optional ideal_counts from JSON if present ideal_counts_json = {} try: if isinstance(json_cfg.get("ideal_counts"), dict): ideal_counts_json = json_cfg["ideal_counts"] except Exception: ideal_counts_json = {} # 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 resolved = { "command_name": _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", defaults["command_name"]), "add_creatures": _resolve_value(args.add_creatures, "DECK_ADD_CREATURES", json_cfg, "add_creatures", defaults["add_creatures"]), "add_non_creature_spells": _resolve_value(args.add_non_creature_spells, "DECK_ADD_NON_CREATURE_SPELLS", json_cfg, "add_non_creature_spells", defaults["add_non_creature_spells"]), "add_ramp": _resolve_value(args.add_ramp, "DECK_ADD_RAMP", json_cfg, "add_ramp", defaults["add_ramp"]), "add_removal": _resolve_value(args.add_removal, "DECK_ADD_REMOVAL", json_cfg, "add_removal", defaults["add_removal"]), "add_wipes": _resolve_value(args.add_wipes, "DECK_ADD_WIPES", json_cfg, "add_wipes", defaults["add_wipes"]), "add_card_advantage": _resolve_value(args.add_card_advantage, "DECK_ADD_CARD_ADVANTAGE", json_cfg, "add_card_advantage", defaults["add_card_advantage"]), "add_protection": _resolve_value(args.add_protection, "DECK_ADD_PROTECTION", json_cfg, "add_protection", defaults["add_protection"]), "primary_choice": _resolve_value(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"]), "bracket_level": _resolve_value(args.bracket_level, "DECK_BRACKET_LEVEL", json_cfg, "bracket_level", None), "add_lands": _resolve_value(args.add_lands, "DECK_ADD_LANDS", json_cfg, "add_lands", defaults["add_lands"]), "fetch_count": _resolve_value(args.fetch_count, "DECK_FETCH_COUNT", json_cfg, "fetch_count", defaults["fetch_count"]), "dual_count": _resolve_value(args.dual_count, "DECK_DUAL_COUNT", json_cfg, "dual_count", defaults["dual_count"]), "triple_count": _resolve_value(args.triple_count, "DECK_TRIPLE_COUNT", json_cfg, "triple_count", defaults["triple_count"]), "utility_count": _resolve_value(args.utility_count, "DECK_UTILITY_COUNT", json_cfg, "utility_count", defaults["utility_count"]), "ideal_counts": ideal_counts_json, # Include/Exclude configuration (M1: Config + Validation + Persistence) "include_cards": include_cards_json, "exclude_cards": exclude_cards_json, "enforcement_mode": json_cfg.get("enforcement_mode", "warn"), "allow_illegal": bool(json_cfg.get("allow_illegal", False)), "fuzzy_matching": bool(json_cfg.get("fuzzy_matching", True)), } if args.dry_run: print(json.dumps(resolved, indent=2)) return 0 # Optional: map tag names from JSON/env to numeric indices for this commander try: primary_tag_name = (str(os.getenv("DECK_PRIMARY_TAG") or "").strip()) or str(json_cfg.get("primary_tag", "")).strip() secondary_tag_name = (str(os.getenv("DECK_SECONDARY_TAG") or "").strip()) or str(json_cfg.get("secondary_tag", "")).strip() tertiary_tag_name = (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: try: # Load commander tags to compute indices tmp = DeckBuilder() df = tmp.load_commander_data() row = df[df["name"] == resolved["command_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: primary_idx = resolved.get("primary_choice") remaining_1 = [t for j, t in enumerate(original, start=1) if j != primary_idx] for i2, t in enumerate(remaining_1, start=1): if str(t).strip().lower() == secondary_tag_name.strip().lower(): resolved["secondary_choice"] = i2 break # Step 3: tertiary from remaining after primary+secondary if tertiary_tag_name and resolved.get("secondary_choice") is not None: primary_idx = resolved.get("primary_choice") secondary_idx = resolved.get("secondary_choice") # reconstruct remaining after removing primary then secondary as displayed remaining_1 = [t for j, t in enumerate(original, start=1) if j != primary_idx] remaining_2 = [t for j, t in enumerate(remaining_1, start=1) if j != secondary_idx] for i3, t in enumerate(remaining_2, start=1): if str(t).strip().lower() == tertiary_tag_name.strip().lower(): resolved["tertiary_choice"] = i3 break except Exception: pass except Exception: pass if not str(resolved.get("command_name", "")).strip(): print("Error: commander is required. Provide --commander or a JSON config with a 'commander' field.") return 2 run(**resolved) return 0 if __name__ == "__main__": raise SystemExit(_main())