mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-22 02:20:13 +01:00
Release v1.1.0: headless runner + tagging updates (Discard Matters, Freerunning, Craft, Spree, Explore/Map, Rad, Energy/Resource Engine, Spawn/Scion)
This commit is contained in:
parent
36abbaa1dd
commit
99005c19f8
23 changed files with 1330 additions and 420 deletions
|
|
@ -103,6 +103,30 @@ class DeckBuilder(
|
|||
txt_path = self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
|
||||
# Display the text file contents for easy copy/paste to online deck builders
|
||||
self._display_txt_contents(txt_path)
|
||||
# Also export a matching JSON config for replay (interactive builds only)
|
||||
if not getattr(self, 'headless', False):
|
||||
try:
|
||||
# Choose config output dir: DECK_CONFIG dir > /app/config > ./config
|
||||
import os as _os
|
||||
cfg_path_env = _os.getenv('DECK_CONFIG')
|
||||
cfg_dir = None
|
||||
if cfg_path_env:
|
||||
cfg_dir = _os.path.dirname(cfg_path_env) or '.'
|
||||
elif _os.path.isdir('/app/config'):
|
||||
cfg_dir = '/app/config'
|
||||
else:
|
||||
cfg_dir = 'config'
|
||||
if cfg_dir:
|
||||
_os.makedirs(cfg_dir, exist_ok=True)
|
||||
self.export_run_config_json(directory=cfg_dir, filename=base + '.json') # type: ignore[attr-defined]
|
||||
# Also, if DECK_CONFIG explicitly points to a file path, write exactly there too
|
||||
if cfg_path_env:
|
||||
cfg_dir2 = _os.path.dirname(cfg_path_env) or '.'
|
||||
cfg_name2 = _os.path.basename(cfg_path_env)
|
||||
_os.makedirs(cfg_dir2, exist_ok=True)
|
||||
self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.warning("Plaintext export failed (non-fatal)")
|
||||
end_ts = datetime.datetime.now()
|
||||
|
|
@ -213,14 +237,18 @@ class DeckBuilder(
|
|||
# IO injection for testing
|
||||
input_func: Callable[[str], str] = field(default=lambda prompt: input(prompt))
|
||||
output_func: Callable[[str], None] = field(default=lambda msg: print(msg))
|
||||
# Deterministic random support
|
||||
seed: Optional[int] = None
|
||||
# Random support (no external seeding)
|
||||
_rng: Any = field(default=None, repr=False)
|
||||
|
||||
# Logging / output behavior
|
||||
log_outputs: bool = True # if True, mirror output_func messages into logger at INFO level
|
||||
_original_output_func: Optional[Callable[[str], None]] = field(default=None, repr=False)
|
||||
|
||||
# Chosen land counts (only fetches are tracked/exported; others vary randomly)
|
||||
fetch_count: Optional[int] = None
|
||||
# Whether this build is running in headless mode (suppress some interactive-only exports)
|
||||
headless: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
"""Post-init hook to wrap the provided output function so that all user-facing
|
||||
messages are also captured in the central log (at INFO level) unless disabled.
|
||||
|
|
@ -250,10 +278,10 @@ class DeckBuilder(
|
|||
# ---------------------------
|
||||
# RNG Initialization
|
||||
# ---------------------------
|
||||
def _get_rng(self): # lazy init to allow seed set post-construction
|
||||
def _get_rng(self): # lazy init
|
||||
if self._rng is None:
|
||||
import random as _r
|
||||
self._rng = _r.Random(self.seed) if self.seed is not None else _r
|
||||
self._rng = _r
|
||||
return self._rng
|
||||
|
||||
# ---------------------------
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ class LandFetchMixin:
|
|||
if len(chosen) < desired:
|
||||
leftovers = [n for n in candidates if n not in chosen]
|
||||
chosen.extend(leftovers[: desired - len(chosen)])
|
||||
|
||||
added: List[str] = []
|
||||
for nm in chosen:
|
||||
if self._current_land_count() >= land_target: # type: ignore[attr-defined]
|
||||
|
|
@ -127,6 +128,11 @@ class LandFetchMixin:
|
|||
added_by='lands_step4'
|
||||
) # type: ignore[attr-defined]
|
||||
added.append(nm)
|
||||
# Record actual number of fetch lands added for export/replay context
|
||||
try:
|
||||
setattr(self, 'fetch_count', len(added)) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
self.output_func("\nFetch Lands Added (Step 4):")
|
||||
if not added:
|
||||
self.output_func(" (None added)")
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ class LandMiscUtilityMixin:
|
|||
self.add_card(nm, card_type='Land', role='utility', sub_role='misc', added_by='lands_step7')
|
||||
added.append(nm)
|
||||
|
||||
|
||||
self.output_func("\nMisc Utility Lands Added (Step 7):")
|
||||
if not added:
|
||||
self.output_func(" (None added)")
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ class LandTripleMixin:
|
|||
)
|
||||
added.append(name)
|
||||
|
||||
|
||||
self.output_func("\nTriple Lands Added (Step 6):")
|
||||
if not added:
|
||||
self.output_func(" (None added)")
|
||||
|
|
|
|||
|
|
@ -383,6 +383,89 @@ class ReportingMixin:
|
|||
self.output_func(f"Plaintext deck list exported to {path}")
|
||||
return path
|
||||
|
||||
def export_run_config_json(self, directory: str = 'config', filename: str | None = None, suppress_output: bool = False) -> str:
|
||||
"""Export a JSON config capturing the key choices for replaying headless.
|
||||
|
||||
Filename mirrors CSV/TXT naming (same stem, .json extension).
|
||||
Fields included:
|
||||
- commander
|
||||
- primary_tag / secondary_tag / tertiary_tag
|
||||
- bracket_level (if chosen)
|
||||
- use_multi_theme (default True)
|
||||
- add_lands, add_creatures, add_non_creature_spells (defaults True)
|
||||
- fetch_count (if determined during run)
|
||||
- ideal_counts (the actual ideal composition values used)
|
||||
"""
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
def _slug(s: str) -> str:
|
||||
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
|
||||
return s2 or 'x'
|
||||
|
||||
def _unique_path(path: str) -> str:
|
||||
if not os.path.exists(path):
|
||||
return path
|
||||
base, ext = os.path.splitext(path)
|
||||
i = 1
|
||||
while True:
|
||||
candidate = f"{base}_{i}{ext}"
|
||||
if not os.path.exists(candidate):
|
||||
return candidate
|
||||
i += 1
|
||||
|
||||
if filename is None:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
date_part = _dt.date.today().strftime('%Y%m%d')
|
||||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.json"
|
||||
|
||||
path = _unique_path(os.path.join(directory, filename))
|
||||
|
||||
# Capture ideal counts (actual chosen values)
|
||||
ideal_counts = getattr(self, 'ideal_counts', {}) or {}
|
||||
# Capture fetch count (others vary run-to-run and are intentionally not recorded)
|
||||
chosen_fetch = getattr(self, 'fetch_count', None)
|
||||
|
||||
payload = {
|
||||
"commander": getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '',
|
||||
"primary_tag": getattr(self, 'primary_tag', None),
|
||||
"secondary_tag": getattr(self, 'secondary_tag', None),
|
||||
"tertiary_tag": getattr(self, 'tertiary_tag', None),
|
||||
"bracket_level": getattr(self, 'bracket_level', None),
|
||||
"use_multi_theme": True,
|
||||
"add_lands": True,
|
||||
"add_creatures": True,
|
||||
"add_non_creature_spells": True,
|
||||
# chosen fetch land count (others intentionally omitted for variance)
|
||||
"fetch_count": chosen_fetch,
|
||||
# actual ideal counts used for this run
|
||||
"ideal_counts": {
|
||||
k: int(v) for k, v in ideal_counts.items() if isinstance(v, (int, float))
|
||||
}
|
||||
# seed intentionally omitted
|
||||
}
|
||||
|
||||
try:
|
||||
import json as _json
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
_json.dump(payload, f, indent=2)
|
||||
if not suppress_output:
|
||||
self.output_func(f"Run config exported to {path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to export run config: {e}")
|
||||
return path
|
||||
|
||||
def print_card_library(self, table: bool = True):
|
||||
"""Prints the current card library in either plain or tabular format.
|
||||
Uses PrettyTable if available, otherwise prints a simple list.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue