diff --git a/.github/workflows/dockerhub-publish.yml b/.github/workflows/dockerhub-publish.yml index 1f63324..ff6de99 100644 --- a/.github/workflows/dockerhub-publish.yml +++ b/.github/workflows/dockerhub-publish.yml @@ -69,3 +69,12 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + - name: Update Docker Hub description + uses: peter-evans/dockerhub-description@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: mwisnowski/mtg-python-deckbuilder + readme-filepath: ./RELEASE_NOTES_TEMPLATE.md + short-description: "CLI MTG Commander deckbuilder with smart tagging, headless mode, CSV/TXT exports, Docker-ready." diff --git a/Dockerfile b/Dockerfile index 5c68cbc..6c8e25e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy application code COPY code/ ./code/ COPY mypy.ini . +COPY config/ ./config/ # Create necessary directories as mount points RUN mkdir -p deck_files logs csv_files config diff --git a/README.md b/README.md index a8ce92f..646dd07 100644 Binary files a/README.md and b/README.md differ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3eeb0e8..8b9c0e4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,16 @@ +# MTG Python Deckbuilder v1.1.2 Release Notes + +Small update focused on reliability and polish. + +## Fixes & Improvements +- Headless: simplified flow and removed project-specific defaults; JSON export remains opt-in in headless. +- Config: ensured correct precedence (CLI > env > JSON > defaults) and improved tag selection by mapping tag names to indices stepwise; respected `bracket_level`. +- Data freshness: auto-refreshes card data if missing or older than 7 days and enforces re-tagging when needed via a `.tagging_complete.json` flag. +- Tagging: fixed Explore/Map pattern error by treating "+1/+1 counter" as a literal; minor stability tweaks. +- Docker: image now ships a default `config/` and docs/scripts clarify mounting `./config` for headless. + +--- + # MTG Python Deckbuilder v1.1.0 Release Notes Note: Future releases will generate this file from `RELEASE_NOTES_TEMPLATE.md` automatically in CI. diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 695ccb2..0ff892a 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,10 +1,12 @@ # MTG Python Deckbuilder ${VERSION} ## Highlights -- Headless mode with a submenu in the main menu (auto-runs single config; lists multiple as "Commander - Theme1, Theme2, Theme3"; `deck.json` labeled "Default") -- Config precedence: CLI > env > JSON > defaults; honors `ideal_counts` in JSON -- Exports: CSV/TXT always; JSON run-config only for interactive runs (headless skips it) -- Smarter filenames: commander + ordered themes + date, with auto-increment when exists +- Headless support: run non-interactively or via the menu's headless submenu. +- Config precedence: CLI > env > JSON > defaults; `ideal_counts` in JSON are honored. +- Exports: CSV/TXT always; JSON run-config is exported for interactive runs. In headless, JSON export is opt-in via `HEADLESS_EXPORT_JSON`. +- Power bracket: set interactively or via `bracket_level` (env: `DECK_BRACKET_LEVEL`). +- Data freshness: auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`. +- Docker: ships a default `config/` in the image; mount `./config` to `/app/config` to use your own. ## Docker - Single service; persistent volumes: @@ -33,22 +35,17 @@ docker compose run --rm -e DECK_MODE=headless -e DECK_CONFIG=/app/config/deck.js ``` ## Changes -- Added headless runner and main menu headless submenu -- JSON export is suppressed in headless; interactive runs export replayable JSON to `config/` -- `ideal_counts` supported and honored by prompts; only `fetch_count` tracked for lands -- Documentation simplified and focused; Docker guide trimmed and PowerShell examples updated +- Simplified headless runner and integrated a headless submenu in the main menu. +- JSON export policy: headless runs skip JSON export by default; opt in with `HEADLESS_EXPORT_JSON`. +- Correct config precedence applied consistently; tag name-to-index mapping improved for multi-step selection; `bracket_level` respected. +- Data freshness enforcement with 7-day refresh and tagging completion flag. +- Documentation and Docker usage clarified; default `config/` now included in the image. ### Tagging updates -- New: Discard Matters theme – detects your discard effects and triggers; includes Madness and Blood creators; Loot/Connive/Cycling/Blood also add Discard Matters. -- New taggers: - - Freerunning → adds Freerunning and Cost Reduction. - - Craft → adds Transform; conditionally Artifacts Matter, Exile Matters, Graveyard Matters. - - Spree → adds Modal and Cost Scaling. - - Explore/Map → adds Card Selection; Explore may add +1/+1 Counters; Map adds Tokens Matter. - - Rad counters → adds Rad Counters. -- Exile Matters expanded to cover Warp and Time Counters/Time Travel/Vanishing. -- Energy enriched to also tag Resource Engine. -- Eldrazi Spawn/Scion creators now tag Aristocrats and Ramp (replacing prior Sacrifice Fodder mapping). +- Explore/Map: fixed a pattern issue by treating "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter. +- Discard Matters theme and enrichments for Loot/Connive/Cycling/Blood. +- Newer mechanics support: Freerunning, Craft, Spree, Rad counters; Time Travel/Vanishing folded into Exile/Time Counters mapping; Energy enriched. +- Spawn/Scion creators now map to Aristocrats and Ramp. ## Known Issues - First run downloads card data (takes a few minutes) diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index 23d1909..f6dcacd 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -74,12 +74,36 @@ class DeckBuilder( try: # Ensure CSVs exist and are tagged before starting any deck build logic try: + import time as _time + import json as _json + from datetime import datetime as _dt cards_path = os.path.join(CSV_DIRECTORY, 'cards.csv') + flag_path = os.path.join(CSV_DIRECTORY, '.tagging_complete.json') + refresh_needed = False if not os.path.exists(cards_path): logger.info("cards.csv not found. Running initial setup and tagging before deck build...") + refresh_needed = True + else: + try: + age_seconds = _time.time() - os.path.getmtime(cards_path) + if age_seconds > 7 * 24 * 60 * 60: + logger.info("cards.csv is older than 7 days. Refreshing data before deck build...") + refresh_needed = True + except Exception: + pass + if not os.path.exists(flag_path): + logger.info("Tagging completion flag not found. Performing full tagging before deck build...") + refresh_needed = True + if refresh_needed: initial_setup() - from tagging import tagger - tagger.run_tagging() + from tagging import tagger as _tagger + _tagger.run_tagging() + try: + os.makedirs(CSV_DIRECTORY, exist_ok=True) + with open(flag_path, 'w', encoding='utf-8') as _fh: + _json.dump({'tagged_at': _dt.now().isoformat(timespec='seconds')}, _fh) + except Exception: + logger.warning("Failed to write tagging completion flag (non-fatal).") except Exception as e: logger.error(f"Failed ensuring CSVs before deck build: {e}") self.run_initial_setup() diff --git a/code/headless_runner.py b/code/headless_runner.py index 66cec5d..21c1aba 100644 --- a/code/headless_runner.py +++ b/code/headless_runner.py @@ -4,50 +4,30 @@ import argparse import json import os from typing import Any, Dict, List, Optional -from pathlib import Path from 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", + command_name: str = "", 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, + 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, ) -> 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. - - """ + """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 @@ -65,8 +45,8 @@ def run( 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") + # 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) for _ in range(8): scripted_inputs.append("") @@ -107,84 +87,88 @@ def run( # 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() + 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) - 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'): + did_bulk = False + if add_non_creature_spells and hasattr(builder, "add_non_creature_spells"): try: - csv_path = builder.export_decklist_csv() + builder.add_non_creature_spells() + did_bulk = True 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') - # 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'): + 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: - cfg_path_env = os.getenv('DECK_CONFIG') - if cfg_path_env and os.path.isdir(os.path.dirname(cfg_path_env) or '.'): - 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 to a file, write exactly there as well - if cfg_path_env and os.path.splitext(cfg_path_env)[1].lower() == '.json': - 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) + 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 - return builder def _parse_bool(val: Optional[str | bool | int]) -> Optional[bool]: if val is None: @@ -232,6 +216,7 @@ def _build_arg_parser() -> argparse.ArgumentParser: 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) @@ -246,9 +231,7 @@ def _build_arg_parser() -> argparse.ArgumentParser: 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 @@ -278,75 +261,27 @@ def _resolve_value( def _main() -> int: parser = _build_arg_parser() args = parser.parse_args() - # Optional config auto-discovery/prompting + # Optional config discovery (no prompts) 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] + # 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="Pantlaza", + command_name="", add_creatures=True, add_non_creature_spells=True, add_ramp=True, @@ -354,10 +289,9 @@ def _main() -> int: add_wipes=True, add_card_advantage=True, add_protection=True, - use_multi_theme=True, - primary_choice=2, - secondary_choice=2, - tertiary_choice=2, + primary_choice=1, + secondary_choice=None, + tertiary_choice=None, add_lands=True, fetch_count=3, dual_count=None, @@ -382,10 +316,10 @@ def _main() -> int: "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"]), + "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"]), @@ -398,6 +332,54 @@ def _main() -> int: 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 diff --git a/code/main.py b/code/main.py index 8485b66..443ec8f 100644 --- a/code/main.py +++ b/code/main.py @@ -35,11 +35,41 @@ def _ensure_data_ready() -> None: # Ensure required CSVs exist and are tagged before proceeding try: + import time + import json as _json + from datetime import datetime as _dt cards_path = os.path.join(CSV_DIRECTORY, 'cards.csv') + flag_path = os.path.join(CSV_DIRECTORY, '.tagging_complete.json') + refresh_needed = False + # Missing CSV forces refresh if not os.path.exists(cards_path): logger.info("cards.csv not found. Running initial setup and tagging...") + refresh_needed = True + else: + # Stale CSV (>7 days) forces refresh + try: + age_seconds = time.time() - os.path.getmtime(cards_path) + if age_seconds > 7 * 24 * 60 * 60: + logger.info("cards.csv is older than 7 days. Refreshing data (setup + tagging)...") + refresh_needed = True + except Exception: + pass + # Missing tagging flag forces refresh + if not os.path.exists(flag_path): + logger.info("Tagging completion flag not found. Performing full tagging...") + refresh_needed = True + if refresh_needed: initial_setup() tagger.run_tagging() + # Write tagging completion flag + try: + os.makedirs(CSV_DIRECTORY, exist_ok=True) + with open(flag_path, 'w', encoding='utf-8') as _fh: + _json.dump({ + 'tagged_at': _dt.now().isoformat(timespec='seconds') + }, _fh) + except Exception: + logger.warning("Failed to write tagging completion flag (non-fatal).") logger.info("Initial setup and tagging completed.") except Exception as e: logger.error(f"Failed ensuring CSVs are ready: {e}") diff --git a/code/non_interactive_test.py b/code/non_interactive_test.py new file mode 100644 index 0000000..e69de29 diff --git a/code/tagging/tagger.py b/code/tagging/tagger.py index 545b7eb..2a3317f 100644 --- a/code/tagging/tagger.py +++ b/code/tagging/tagger.py @@ -2817,7 +2817,8 @@ def tag_for_explore_and_map(df: pd.DataFrame, color: str) -> None: if explore_mask.any(): rules.append({ 'mask': explore_mask, 'tags': ['Card Selection'] }) # If the text also references +1/+1 counters, add that theme - explore_counters = explore_mask & tag_utils.create_text_mask(df, ['+1/+1 counter']) + # Use literal match for '+1/+1 counter' to avoid regex errors on '+' at start + explore_counters = explore_mask & tag_utils.create_text_mask(df, ['+1/+1 counter'], regex=False) if explore_counters.any(): rules.append({ 'mask': explore_counters, 'tags': ['+1/+1 Counters'] }) if map_mask.any(): diff --git a/pyproject.toml b/pyproject.toml index b95439d..9ef4078 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mtg-deckbuilder" -version = "1.1.0" +version = "1.1.2" description = "A command-line tool for building and analyzing Magic: The Gathering decks" readme = "README.md" license = {file = "LICENSE"} diff --git a/run-from-dockerhub.bat b/run-from-dockerhub.bat index 58754b0..acac548 100644 --- a/run-from-dockerhub.bat +++ b/run-from-dockerhub.bat @@ -6,12 +6,14 @@ REM Create directories if they don't exist if not exist "deck_files" mkdir deck_files if not exist "logs" mkdir logs if not exist "csv_files" mkdir csv_files +if not exist "config" mkdir config echo Starting MTG Python Deckbuilder from Docker Hub... echo Your files will be saved in the current directory: echo - deck_files\: Your completed decks echo - logs\: Application logs echo - csv_files\: Card database files +echo - config\: JSON configs for headless runs (e.g., deck.json) echo. REM Run the Docker container with proper volume mounts @@ -19,9 +21,14 @@ docker run -it --rm ^ -v "%cd%\deck_files:/app/deck_files" ^ -v "%cd%\logs:/app/logs" ^ -v "%cd%\csv_files:/app/csv_files" ^ + -v "%cd%\config:/app/config" ^ mwisnowski/mtg-python-deckbuilder:latest echo. echo MTG Python Deckbuilder session ended. echo Your files are saved in: %cd% +echo. +echo Tips: +echo - For headless: set environment variables, e.g. -e DECK_MODE=headless -e DECK_CONFIG=/app/config/deck.json +echo - If the container seems to use an old config, mount the config folder (done above) or prune anonymous volumes. pause diff --git a/run-from-dockerhub.sh b/run-from-dockerhub.sh index 1a7c0e7..73271b6 100644 --- a/run-from-dockerhub.sh +++ b/run-from-dockerhub.sh @@ -5,12 +5,14 @@ echo "===========================================" # Create directories if they don't exist mkdir -p deck_files logs csv_files +mkdir -p config echo "Starting MTG Python Deckbuilder from Docker Hub..." echo "Your files will be saved in the current directory:" echo " - deck_files/: Your completed decks" echo " - logs/: Application logs" echo " - csv_files/: Card database files" +echo " - config/: JSON configs for headless runs (e.g., deck.json)" echo # Run the Docker container with proper volume mounts @@ -18,6 +20,7 @@ docker run -it --rm \ -v "$(pwd)/deck_files":/app/deck_files \ -v "$(pwd)/logs":/app/logs \ -v "$(pwd)/csv_files":/app/csv_files \ + -v "$(pwd)/config":/app/config \ mwisnowski/mtg-python-deckbuilder:latest echo