mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
chore(release): v1.1.2 bump, notes/template + README updates, Docker Hub description updater, headless/docs tweaks
This commit is contained in:
parent
fd2530cea3
commit
5f922835a6
13 changed files with 250 additions and 183 deletions
9
.github/workflows/dockerhub-publish.yml
vendored
9
.github/workflows/dockerhub-publish.yml
vendored
|
@ -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."
|
||||||
|
|
|
@ -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
BIN
README.md
Binary file not shown.
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
30
code/main.py
30
code/main.py
|
@ -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}")
|
||||||
|
|
0
code/non_interactive_test.py
Normal file
0
code/non_interactive_test.py
Normal 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():
|
||||||
|
|
|
@ -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"}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue