chore(release): v1.1.2 bump, notes/template + README updates, Docker Hub description updater, headless/docs tweaks

This commit is contained in:
matt 2025-08-23 15:29:45 -07:00
parent fd2530cea3
commit 5f922835a6
13 changed files with 250 additions and 183 deletions

View file

@ -69,3 +69,12 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} 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."

View file

@ -22,6 +22,7 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY code/ ./code/ COPY code/ ./code/
COPY mypy.ini . COPY mypy.ini .
COPY config/ ./config/
# Create necessary directories as mount points # Create necessary directories as mount points
RUN mkdir -p deck_files logs csv_files config RUN mkdir -p deck_files logs csv_files config

BIN
README.md

Binary file not shown.

View file

@ -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 # MTG Python Deckbuilder v1.1.0 Release Notes
Note: Future releases will generate this file from `RELEASE_NOTES_TEMPLATE.md` automatically in CI. Note: Future releases will generate this file from `RELEASE_NOTES_TEMPLATE.md` automatically in CI.

View file

@ -1,10 +1,12 @@
# MTG Python Deckbuilder ${VERSION} # MTG Python Deckbuilder ${VERSION}
## Highlights ## 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") - Headless support: run non-interactively or via the menu's headless submenu.
- Config precedence: CLI > env > JSON > defaults; honors `ideal_counts` in JSON - Config precedence: CLI > env > JSON > defaults; `ideal_counts` in JSON are honored.
- Exports: CSV/TXT always; JSON run-config only for interactive runs (headless skips it) - Exports: CSV/TXT always; JSON run-config is exported for interactive runs. In headless, JSON export is opt-in via `HEADLESS_EXPORT_JSON`.
- Smarter filenames: commander + ordered themes + date, with auto-increment when exists - 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 ## Docker
- Single service; persistent volumes: - Single service; persistent volumes:
@ -33,22 +35,17 @@ docker compose run --rm -e DECK_MODE=headless -e DECK_CONFIG=/app/config/deck.js
``` ```
## Changes ## Changes
- Added headless runner and main menu headless submenu - Simplified headless runner and integrated a headless submenu in the main menu.
- JSON export is suppressed in headless; interactive runs export replayable JSON to `config/` - JSON export policy: headless runs skip JSON export by default; opt in with `HEADLESS_EXPORT_JSON`.
- `ideal_counts` supported and honored by prompts; only `fetch_count` tracked for lands - Correct config precedence applied consistently; tag name-to-index mapping improved for multi-step selection; `bracket_level` respected.
- Documentation simplified and focused; Docker guide trimmed and PowerShell examples updated - 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 ### 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. - 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.
- New taggers: - Discard Matters theme and enrichments for Loot/Connive/Cycling/Blood.
- Freerunning → adds Freerunning and Cost Reduction. - Newer mechanics support: Freerunning, Craft, Spree, Rad counters; Time Travel/Vanishing folded into Exile/Time Counters mapping; Energy enriched.
- Craft → adds Transform; conditionally Artifacts Matter, Exile Matters, Graveyard Matters. - Spawn/Scion creators now map to Aristocrats and Ramp.
- 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).
## Known Issues ## Known Issues
- First run downloads card data (takes a few minutes) - First run downloads card data (takes a few minutes)

View file

@ -74,12 +74,36 @@ class DeckBuilder(
try: try:
# Ensure CSVs exist and are tagged before starting any deck build logic # Ensure CSVs exist and are tagged before starting any deck build logic
try: try:
import time as _time
import json as _json
from datetime import datetime as _dt
cards_path = os.path.join(CSV_DIRECTORY, 'cards.csv') 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): if not os.path.exists(cards_path):
logger.info("cards.csv not found. Running initial setup and tagging before deck build...") 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() initial_setup()
from tagging import tagger from tagging import tagger as _tagger
tagger.run_tagging() _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: except Exception as e:
logger.error(f"Failed ensuring CSVs before deck build: {e}") logger.error(f"Failed ensuring CSVs before deck build: {e}")
self.run_initial_setup() self.run_initial_setup()

View file

@ -4,50 +4,30 @@ import argparse
import json import json
import os import os
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from pathlib import Path
from deck_builder.builder import DeckBuilder 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( def run(
command_name: str = "Pantlaza", command_name: str = "",
add_creatures: bool = True, add_creatures: bool = True,
add_non_creature_spells: 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_ramp: bool = True,
add_removal: bool = True, add_removal: bool = True,
add_wipes: bool = True, add_wipes: bool = True,
add_card_advantage: bool = True, add_card_advantage: bool = True,
add_protection: bool = True, add_protection: bool = True,
use_multi_theme: bool = True, primary_choice: int = 1,
primary_choice: int = 2, secondary_choice: Optional[int] = None,
secondary_choice: Optional[int] = 2, tertiary_choice: Optional[int] = None,
tertiary_choice: Optional[int] = 2,
add_lands: bool = True, add_lands: bool = True,
fetch_count: Optional[int] = 3, fetch_count: Optional[int] = 3,
dual_count: Optional[int] = None, dual_count: Optional[int] = None,
triple_count: Optional[int] = None, triple_count: Optional[int] = None,
utility_count: Optional[int] = None, utility_count: Optional[int] = None,
ideal_counts: Optional[Dict[str, int]] = None, ideal_counts: Optional[Dict[str, int]] = None,
bracket_level: Optional[int] = None,
) -> DeckBuilder: ) -> DeckBuilder:
"""Run a scripted non-interactive deck build and return the DeckBuilder instance. """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] = [] scripted_inputs: List[str] = []
# Commander query & selection # Commander query & selection
scripted_inputs.append(command_name) # initial query scripted_inputs.append(command_name) # initial query
@ -65,8 +45,8 @@ def run(
scripted_inputs.append("0") scripted_inputs.append("0")
else: else:
scripted_inputs.append("0") # stop at primary scripted_inputs.append("0") # stop at primary
# Bracket (meta power / style) selection; keeping existing scripted value # Bracket (meta power / style) selection; default to 3 if not provided
scripted_inputs.append("3") 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) # Ideal count prompts (press Enter for defaults)
for _ in range(8): for _ in range(8):
scripted_inputs.append("") scripted_inputs.append("")
@ -107,84 +87,88 @@ def run(
# Land sequence (optional) # Land sequence (optional)
if add_lands: if add_lands:
if hasattr(builder, 'run_land_step1'): def call(method: str, **kwargs: Any) -> None:
builder.run_land_step1() # Basics / initial fn = getattr(builder, method, None)
if hasattr(builder, 'run_land_step2'): if callable(fn):
builder.run_land_step2() # Utility basics / rebalancing try:
if hasattr(builder, 'run_land_step3'): fn(**kwargs)
builder.run_land_step3() # Kindred lands if applicable except Exception:
if hasattr(builder, 'run_land_step4'): pass
builder.run_land_step4(requested_count=fetch_count) for method, kwargs in [
if hasattr(builder, 'run_land_step5'): ("run_land_step1", {}),
builder.run_land_step5(requested_count=dual_count) ("run_land_step2", {}),
if hasattr(builder, 'run_land_step6'): ("run_land_step3", {}),
builder.run_land_step6(requested_count=triple_count) ("run_land_step4", {"requested_count": fetch_count}),
if hasattr(builder, 'run_land_step7'): ("run_land_step5", {"requested_count": dual_count}),
("run_land_step6", {"requested_count": triple_count}),
builder.run_land_step7(requested_count=utility_count) ("run_land_step7", {"requested_count": utility_count}),
if hasattr(builder, 'run_land_step8'): ("run_land_step8", {}),
builder.run_land_step8() ]:
call(method, **kwargs)
if add_creatures: if add_creatures:
builder.add_creatures() builder.add_creatures()
# Non-creature spell categories (ramp / removal / wipes / draw / protection) # Non-creature spell categories (ramp / removal / wipes / draw / protection)
if add_non_creature_spells and hasattr(builder, 'add_non_creature_spells'): did_bulk = False
if add_non_creature_spells and hasattr(builder, "add_non_creature_spells"):
try:
builder.add_non_creature_spells() builder.add_non_creature_spells()
else: did_bulk = True
# 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: except Exception:
csv_path = None did_bulk = False
if hasattr(builder, 'export_decklist_text'): 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: try:
if csv_path: fn()
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'):
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)
except Exception: except Exception:
pass 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: else:
builder.export_decklist_text() builder.export_decklist_text()
except Exception: except Exception:
pass pass
return builder 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]: def _parse_bool(val: Optional[str | bool | int]) -> Optional[bool]:
if val is None: 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("--primary-choice", type=int, default=None)
p.add_argument("--secondary-choice", type=_parse_opt_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("--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("--add-lands", type=_parse_bool, default=None)
p.add_argument("--fetch-count", type=_parse_opt_int, 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("--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-wipes", type=_parse_bool, default=None)
p.add_argument("--add-card-advantage", 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("--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("--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 return p
@ -278,75 +261,27 @@ def _resolve_value(
def _main() -> int: def _main() -> int:
parser = _build_arg_parser() parser = _build_arg_parser()
args = parser.parse_args() args = parser.parse_args()
# Optional config auto-discovery/prompting # Optional config discovery (no prompts)
cfg_path = args.config cfg_path = args.config
json_cfg: Dict[str, Any] = {} 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): if cfg_path and os.path.isfile(cfg_path):
json_cfg = _load_json_config(cfg_path) json_cfg = _load_json_config(cfg_path)
else: else:
# If auto-select is requested, we may prompt user to choose a config # No explicit file; if exactly one config exists in a known dir, use it
configs = _discover_json_configs() for candidate_dir in [cfg_path] if cfg_path and os.path.isdir(cfg_path) else ["/app/config", "config"]:
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: try:
with open(p, 'r', encoding='utf-8') as fh: files = [f for f in (os.listdir(candidate_dir) if os.path.isdir(candidate_dir) else []) if f.lower().endswith(".json")]
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: except Exception:
return p files = []
print("\nAvailable JSON configs:") if len(files) == 1:
for idx, f in enumerate(configs, start=1): chosen = os.path.join(candidate_dir, files[0])
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) json_cfg = _load_json_config(chosen)
os.environ['DECK_CONFIG'] = chosen os.environ["DECK_CONFIG"] = chosen
break 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 mirror run() signature
defaults = dict( defaults = dict(
command_name="Pantlaza", command_name="",
add_creatures=True, add_creatures=True,
add_non_creature_spells=True, add_non_creature_spells=True,
add_ramp=True, add_ramp=True,
@ -354,10 +289,9 @@ def _main() -> int:
add_wipes=True, add_wipes=True,
add_card_advantage=True, add_card_advantage=True,
add_protection=True, add_protection=True,
use_multi_theme=True, primary_choice=1,
primary_choice=2, secondary_choice=None,
secondary_choice=2, tertiary_choice=None,
tertiary_choice=2,
add_lands=True, add_lands=True,
fetch_count=3, fetch_count=3,
dual_count=None, 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_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_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"]), "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"]), "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"]), "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"]), "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"]), "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"]), "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"]), "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)) print(json.dumps(resolved, indent=2))
return 0 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) run(**resolved)
return 0 return 0

View file

@ -35,11 +35,41 @@ def _ensure_data_ready() -> None:
# Ensure required CSVs exist and are tagged before proceeding # Ensure required CSVs exist and are tagged before proceeding
try: try:
import time
import json as _json
from datetime import datetime as _dt
cards_path = os.path.join(CSV_DIRECTORY, 'cards.csv') 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): if not os.path.exists(cards_path):
logger.info("cards.csv not found. Running initial setup and tagging...") 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() initial_setup()
tagger.run_tagging() 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.") logger.info("Initial setup and tagging completed.")
except Exception as e: except Exception as e:
logger.error(f"Failed ensuring CSVs are ready: {e}") logger.error(f"Failed ensuring CSVs are ready: {e}")

View file

View file

@ -2817,7 +2817,8 @@ def tag_for_explore_and_map(df: pd.DataFrame, color: str) -> None:
if explore_mask.any(): if explore_mask.any():
rules.append({ 'mask': explore_mask, 'tags': ['Card Selection'] }) rules.append({ 'mask': explore_mask, 'tags': ['Card Selection'] })
# If the text also references +1/+1 counters, add that theme # 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(): if explore_counters.any():
rules.append({ 'mask': explore_counters, 'tags': ['+1/+1 Counters'] }) rules.append({ 'mask': explore_counters, 'tags': ['+1/+1 Counters'] })
if map_mask.any(): if map_mask.any():

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "mtg-deckbuilder" name = "mtg-deckbuilder"
version = "1.1.0" version = "1.1.2"
description = "A command-line tool for building and analyzing Magic: The Gathering decks" description = "A command-line tool for building and analyzing Magic: The Gathering decks"
readme = "README.md" readme = "README.md"
license = {file = "LICENSE"} license = {file = "LICENSE"}

View file

@ -6,12 +6,14 @@ REM Create directories if they don't exist
if not exist "deck_files" mkdir deck_files if not exist "deck_files" mkdir deck_files
if not exist "logs" mkdir logs if not exist "logs" mkdir logs
if not exist "csv_files" mkdir csv_files if not exist "csv_files" mkdir csv_files
if not exist "config" mkdir config
echo Starting MTG Python Deckbuilder from Docker Hub... echo Starting MTG Python Deckbuilder from Docker Hub...
echo Your files will be saved in the current directory: echo Your files will be saved in the current directory:
echo - deck_files\: Your completed decks echo - deck_files\: Your completed decks
echo - logs\: Application logs echo - logs\: Application logs
echo - csv_files\: Card database files echo - csv_files\: Card database files
echo - config\: JSON configs for headless runs (e.g., deck.json)
echo. echo.
REM Run the Docker container with proper volume mounts 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%\deck_files:/app/deck_files" ^
-v "%cd%\logs:/app/logs" ^ -v "%cd%\logs:/app/logs" ^
-v "%cd%\csv_files:/app/csv_files" ^ -v "%cd%\csv_files:/app/csv_files" ^
-v "%cd%\config:/app/config" ^
mwisnowski/mtg-python-deckbuilder:latest mwisnowski/mtg-python-deckbuilder:latest
echo. echo.
echo MTG Python Deckbuilder session ended. echo MTG Python Deckbuilder session ended.
echo Your files are saved in: %cd% 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 pause

View file

@ -5,12 +5,14 @@ echo "==========================================="
# Create directories if they don't exist # Create directories if they don't exist
mkdir -p deck_files logs csv_files mkdir -p deck_files logs csv_files
mkdir -p config
echo "Starting MTG Python Deckbuilder from Docker Hub..." echo "Starting MTG Python Deckbuilder from Docker Hub..."
echo "Your files will be saved in the current directory:" echo "Your files will be saved in the current directory:"
echo " - deck_files/: Your completed decks" echo " - deck_files/: Your completed decks"
echo " - logs/: Application logs" echo " - logs/: Application logs"
echo " - csv_files/: Card database files" echo " - csv_files/: Card database files"
echo " - config/: JSON configs for headless runs (e.g., deck.json)"
echo echo
# Run the Docker container with proper volume mounts # 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)/deck_files":/app/deck_files \
-v "$(pwd)/logs":/app/logs \ -v "$(pwd)/logs":/app/logs \
-v "$(pwd)/csv_files":/app/csv_files \ -v "$(pwd)/csv_files":/app/csv_files \
-v "$(pwd)/config":/app/config \
mwisnowski/mtg-python-deckbuilder:latest mwisnowski/mtg-python-deckbuilder:latest
echo echo