mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
Finished modularization with assistance from Github copilot
This commit is contained in:
parent
d9b56d8e12
commit
0135eeeb3d
6 changed files with 202 additions and 139 deletions
11
code/__init__.py
Normal file
11
code/__init__.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
"""Root package for the MTG deckbuilder source tree.
|
||||||
|
|
||||||
|
Adding this file ensures the directory is treated as a proper package so that
|
||||||
|
`python -m code.main` resolves to this project instead of the Python stdlib
|
||||||
|
module named `code` (which is a simple module, not a package).
|
||||||
|
|
||||||
|
If you still accidentally import the stdlib module, be sure you are executing
|
||||||
|
from the project root so the local `code` package is first on sys.path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = []
|
|
@ -49,7 +49,8 @@ if not any(isinstance(h, logging_util.logging.StreamHandler) for h in logger.han
|
||||||
## Phase 0 extraction: BracketDefinition & BRACKET_DEFINITIONS now imported
|
## Phase 0 extraction: BracketDefinition & BRACKET_DEFINITIONS now imported
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DeckBuilder(CommanderSelectionMixin,
|
class DeckBuilder(
|
||||||
|
CommanderSelectionMixin,
|
||||||
LandBasicsMixin,
|
LandBasicsMixin,
|
||||||
LandStaplesMixin,
|
LandStaplesMixin,
|
||||||
LandKindredMixin,
|
LandKindredMixin,
|
||||||
|
@ -61,7 +62,54 @@ class DeckBuilder(CommanderSelectionMixin,
|
||||||
CreatureAdditionMixin,
|
CreatureAdditionMixin,
|
||||||
SpellAdditionMixin,
|
SpellAdditionMixin,
|
||||||
ColorBalanceMixin,
|
ColorBalanceMixin,
|
||||||
ReportingMixin):
|
ReportingMixin
|
||||||
|
):
|
||||||
|
def build_deck_full(self):
|
||||||
|
"""Orchestrate the full deck build process, chaining all major phases."""
|
||||||
|
start_ts = datetime.datetime.now()
|
||||||
|
logger.info("=== Deck Build: BEGIN ===")
|
||||||
|
try:
|
||||||
|
self.run_initial_setup()
|
||||||
|
self.run_deck_build_step1()
|
||||||
|
self.run_deck_build_step2()
|
||||||
|
self._run_land_build_steps()
|
||||||
|
if hasattr(self, 'add_creatures_phase'):
|
||||||
|
self.add_creatures_phase()
|
||||||
|
if hasattr(self, 'add_spells_phase'):
|
||||||
|
self.add_spells_phase()
|
||||||
|
if hasattr(self, 'post_spell_land_adjust'):
|
||||||
|
self.post_spell_land_adjust()
|
||||||
|
# Modular reporting phase
|
||||||
|
if hasattr(self, 'run_reporting_phase'):
|
||||||
|
self.run_reporting_phase()
|
||||||
|
if hasattr(self, 'export_decklist_csv'):
|
||||||
|
csv_path = self.export_decklist_csv()
|
||||||
|
try:
|
||||||
|
import os as _os
|
||||||
|
base, _ext = _os.path.splitext(_os.path.basename(csv_path))
|
||||||
|
self.export_decklist_text(filename=base + '.txt') # type: ignore[attr_defined]
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Plaintext export failed (non-fatal)")
|
||||||
|
end_ts = datetime.datetime.now()
|
||||||
|
logger.info(f"=== Deck Build: COMPLETE in {(end_ts - start_ts).total_seconds():.2f}s ===")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.warning("Deck build cancelled by user (KeyboardInterrupt).")
|
||||||
|
self.output_func("\nDeck build cancelled by user.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Deck build failed with exception")
|
||||||
|
self.output_func(f"Deck build failed: {e}")
|
||||||
|
|
||||||
|
def add_creatures_phase(self):
|
||||||
|
"""Run the creature addition phase (delegated to CreatureAdditionMixin)."""
|
||||||
|
if hasattr(super(), 'add_creatures_phase'):
|
||||||
|
return super().add_creatures_phase()
|
||||||
|
raise NotImplementedError("Creature addition phase not implemented.")
|
||||||
|
|
||||||
|
def add_spells_phase(self):
|
||||||
|
"""Run the spell addition phase (delegated to SpellAdditionMixin)."""
|
||||||
|
if hasattr(super(), 'add_spells_phase'):
|
||||||
|
return super().add_spells_phase()
|
||||||
|
raise NotImplementedError("Spell addition phase not implemented.")
|
||||||
# Commander core selection state
|
# Commander core selection state
|
||||||
commander_name: str = ""
|
commander_name: str = ""
|
||||||
commander_row: Optional[pd.Series] = None
|
commander_row: Optional[pd.Series] = None
|
||||||
|
@ -135,93 +183,22 @@ class DeckBuilder(CommanderSelectionMixin,
|
||||||
self._original_output_func = self.output_func
|
self._original_output_func = self.output_func
|
||||||
|
|
||||||
def _wrapped(msg: str):
|
def _wrapped(msg: str):
|
||||||
try:
|
|
||||||
# Collapse excessive blank lines for log readability, but keep printing original
|
# Collapse excessive blank lines for log readability, but keep printing original
|
||||||
log_msg = msg.rstrip()
|
log_msg = msg.rstrip()
|
||||||
if log_msg:
|
if log_msg:
|
||||||
logger.info(log_msg)
|
logger.info(log_msg)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._original_output_func(msg)
|
self._original_output_func(msg)
|
||||||
|
|
||||||
self.output_func = _wrapped # type: ignore
|
self.output_func = _wrapped
|
||||||
|
|
||||||
# Internal explicit logging helper for code paths where we do NOT want to echo to user
|
def _run_land_build_steps(self):
|
||||||
def _log_debug(self, msg: str):
|
"""Run all land build steps (1-8) in order, logging progress."""
|
||||||
logger.debug(msg)
|
|
||||||
|
|
||||||
def _log_info(self, msg: str):
|
|
||||||
logger.info(msg)
|
|
||||||
|
|
||||||
def _log_warning(self, msg: str):
|
|
||||||
logger.warning(msg)
|
|
||||||
|
|
||||||
def _log_error(self, msg: str):
|
|
||||||
logger.error(msg)
|
|
||||||
|
|
||||||
# ---------------------------
|
|
||||||
# High-level Orchestration
|
|
||||||
# ---------------------------
|
|
||||||
def build_deck_full(self) -> None:
|
|
||||||
"""Run the full interactive deck building pipeline and export deck CSV.
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. Commander selection & tag prioritization
|
|
||||||
2. Power bracket & ideal composition inputs
|
|
||||||
3. Land building steps (1-8)
|
|
||||||
4. Creature addition (theme-weighted)
|
|
||||||
5. Non-creature spell categories & filler
|
|
||||||
6. Post-spell land color balancing & basic rebalance
|
|
||||||
7. CSV export (deck_files/<name>_<date>.csv)
|
|
||||||
"""
|
|
||||||
logger.info("=== Deck Build: START ===")
|
|
||||||
start_ts = datetime.datetime.now()
|
|
||||||
try:
|
|
||||||
logger.info("Step 0: Initial setup")
|
|
||||||
self.run_initial_setup()
|
|
||||||
logger.info("Step 1: Commander selection & tag prioritization complete")
|
|
||||||
self.run_deck_build_step1()
|
|
||||||
logger.info("Step 2: Power bracket & composition inputs")
|
|
||||||
self.run_deck_build_step2()
|
|
||||||
# Land steps (1-8)
|
|
||||||
for step in range(1, 9):
|
for step in range(1, 9):
|
||||||
m = getattr(self, f"run_land_step{step}", None)
|
m = getattr(self, f"run_land_step{step}", None)
|
||||||
if callable(m):
|
if callable(m):
|
||||||
logger.info(f"Land Step {step}: begin")
|
logger.info(f"Land Step {step}: begin")
|
||||||
m()
|
m()
|
||||||
logger.info(f"Land Step {step}: complete (current land count {self._current_land_count() if hasattr(self, '_current_land_count') else 'n/a'})")
|
logger.info(f"Land Step {step}: complete (current land count {self._current_land_count() if hasattr(self, '_current_land_count') else 'n/a'})")
|
||||||
# Creatures
|
|
||||||
if hasattr(self, 'add_creatures'):
|
|
||||||
logger.info("Adding creatures phase")
|
|
||||||
self.add_creatures()
|
|
||||||
# Non-creature spells
|
|
||||||
if hasattr(self, 'add_non_creature_spells'):
|
|
||||||
logger.info("Adding non-creature spells phase")
|
|
||||||
self.add_non_creature_spells()
|
|
||||||
# Post-spell land adjustments
|
|
||||||
if hasattr(self, 'post_spell_land_adjust'):
|
|
||||||
logger.info("Post-spell land adjustment phase")
|
|
||||||
self.post_spell_land_adjust()
|
|
||||||
# Export
|
|
||||||
if hasattr(self, 'export_decklist_csv'):
|
|
||||||
logger.info("Export decklist phase")
|
|
||||||
csv_path = self.export_decklist_csv()
|
|
||||||
# Also emit plaintext list (.txt) for quick copy/paste
|
|
||||||
try:
|
|
||||||
# Derive matching stem by replacing extension from csv_path
|
|
||||||
import os as _os
|
|
||||||
base, _ext = _os.path.splitext(_os.path.basename(csv_path))
|
|
||||||
self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Plaintext export failed (non-fatal)")
|
|
||||||
end_ts = datetime.datetime.now()
|
|
||||||
logger.info(f"=== Deck Build: COMPLETE in {(end_ts - start_ts).total_seconds():.2f}s ===")
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
logger.warning("Deck build cancelled by user (KeyboardInterrupt).")
|
|
||||||
self.output_func("\nDeck build cancelled by user.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Deck build failed with exception")
|
|
||||||
self.output_func(f"Deck build failed: {e}")
|
|
||||||
|
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# RNG Initialization
|
# RNG Initialization
|
||||||
|
|
|
@ -19,7 +19,11 @@ class CreatureAdditionMixin:
|
||||||
- Avoid duplicating the commander
|
- Avoid duplicating the commander
|
||||||
- Deterministic weighted sampling via builder_utils helper
|
- Deterministic weighted sampling via builder_utils helper
|
||||||
"""
|
"""
|
||||||
def add_creatures(self): # noqa: C901 (complexity preserved during extraction)
|
def add_creatures(self):
|
||||||
|
"""Add creatures to the deck based on selected themes and allocation weights.
|
||||||
|
Applies kindred/tribal multipliers, prioritizes multi-theme matches, and avoids commander duplication.
|
||||||
|
Uses weighted sampling for selection and fills shortfall if needed.
|
||||||
|
"""
|
||||||
df = getattr(self, '_combined_cards_df', None)
|
df = getattr(self, '_combined_cards_df', None)
|
||||||
if df is None or df.empty:
|
if df is None or df.empty:
|
||||||
self.output_func("Card pool not loaded; cannot add creatures.")
|
self.output_func("Card pool not loaded; cannot add creatures.")
|
||||||
|
@ -179,3 +183,10 @@ class CreatureAdditionMixin:
|
||||||
else:
|
else:
|
||||||
self.output_func(f" {role.title()} '{tag}': 0")
|
self.output_func(f" {role.title()} '{tag}': 0")
|
||||||
self.output_func(f" Total {total_added}/{desired_total}{' (dataset shortfall)' if total_added < desired_total else ''}")
|
self.output_func(f" Total {total_added}/{desired_total}{' (dataset shortfall)' if total_added < desired_total else ''}")
|
||||||
|
|
||||||
|
def add_creatures_phase(self):
|
||||||
|
"""Public method for orchestration: delegates to add_creatures.
|
||||||
|
Use this as the main entry point for the creature addition phase in deck building.
|
||||||
|
"""
|
||||||
|
"""Public method for orchestration: delegates to add_creatures."""
|
||||||
|
return self.add_creatures()
|
||||||
|
|
|
@ -120,7 +120,11 @@ class SpellAdditionMixin:
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Removal
|
# Removal
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
def add_removal(self): # noqa: C901
|
def add_removal(self):
|
||||||
|
"""Add spot removal spells to the deck, avoiding board wipes and lands.
|
||||||
|
Selects cards tagged as 'removal' or 'spot removal', prioritizing by EDHREC rank and mana value.
|
||||||
|
Avoids duplicates and commander card.
|
||||||
|
"""
|
||||||
target = self.ideal_counts.get('removal', 0)
|
target = self.ideal_counts.get('removal', 0)
|
||||||
if target <= 0 or self._combined_cards_df is None:
|
if target <= 0 or self._combined_cards_df is None:
|
||||||
return
|
return
|
||||||
|
@ -179,6 +183,10 @@ class SpellAdditionMixin:
|
||||||
# Board Wipes
|
# Board Wipes
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
def add_board_wipes(self):
|
def add_board_wipes(self):
|
||||||
|
"""Add board wipe spells to the deck.
|
||||||
|
Selects cards tagged as 'board wipe' or 'mass removal', prioritizing by EDHREC rank and mana value.
|
||||||
|
Avoids duplicates and commander card.
|
||||||
|
"""
|
||||||
target = self.ideal_counts.get('wipes', 0)
|
target = self.ideal_counts.get('wipes', 0)
|
||||||
if target <= 0 or self._combined_cards_df is None:
|
if target <= 0 or self._combined_cards_df is None:
|
||||||
return
|
return
|
||||||
|
@ -232,7 +240,11 @@ class SpellAdditionMixin:
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Card Advantage
|
# Card Advantage
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
def add_card_advantage(self): # noqa: C901
|
def add_card_advantage(self):
|
||||||
|
"""Add card advantage spells to the deck.
|
||||||
|
Selects cards tagged as 'draw' or 'card advantage', splits between conditional and unconditional draw.
|
||||||
|
Prioritizes by EDHREC rank and mana value, avoids duplicates and commander card.
|
||||||
|
"""
|
||||||
total_target = self.ideal_counts.get('card_advantage', 0)
|
total_target = self.ideal_counts.get('card_advantage', 0)
|
||||||
if total_target <= 0 or self._combined_cards_df is None:
|
if total_target <= 0 or self._combined_cards_df is None:
|
||||||
return
|
return
|
||||||
|
@ -321,6 +333,10 @@ class SpellAdditionMixin:
|
||||||
# Protection
|
# Protection
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
def add_protection(self):
|
def add_protection(self):
|
||||||
|
"""Add protection spells to the deck.
|
||||||
|
Selects cards tagged as 'protection', prioritizing by EDHREC rank and mana value.
|
||||||
|
Avoids duplicates and commander card.
|
||||||
|
"""
|
||||||
target = self.ideal_counts.get('protection', 0)
|
target = self.ideal_counts.get('protection', 0)
|
||||||
if target <= 0 or self._combined_cards_df is None:
|
if target <= 0 or self._combined_cards_df is None:
|
||||||
return
|
return
|
||||||
|
@ -371,7 +387,11 @@ class SpellAdditionMixin:
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Theme Spell Filler to 100
|
# Theme Spell Filler to 100
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
def fill_remaining_theme_spells(self): # noqa: C901
|
def fill_remaining_theme_spells(self):
|
||||||
|
"""Fill remaining deck slots with theme spells to reach 100 cards.
|
||||||
|
Uses primary, secondary, and tertiary tags to select spells matching deck themes.
|
||||||
|
Applies weighted selection and fallback to general utility spells if needed.
|
||||||
|
"""
|
||||||
total_cards = sum(entry.get('Count', 1) for entry in self.card_library.values())
|
total_cards = sum(entry.get('Count', 1) for entry in self.card_library.values())
|
||||||
remaining = 100 - total_cards
|
remaining = 100 - total_cards
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
|
@ -603,6 +623,9 @@ class SpellAdditionMixin:
|
||||||
# Orchestrator
|
# Orchestrator
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
def add_non_creature_spells(self):
|
def add_non_creature_spells(self):
|
||||||
|
"""Orchestrate addition of all non-creature spell categories and theme filler.
|
||||||
|
Calls ramp, removal, board wipes, card advantage, protection, and theme filler methods in order.
|
||||||
|
"""
|
||||||
"""Convenience orchestrator calling remaining non-creature spell categories then thematic fill."""
|
"""Convenience orchestrator calling remaining non-creature spell categories then thematic fill."""
|
||||||
self.add_ramp()
|
self.add_ramp()
|
||||||
self.add_removal()
|
self.add_removal()
|
||||||
|
@ -611,3 +634,11 @@ class SpellAdditionMixin:
|
||||||
self.add_protection()
|
self.add_protection()
|
||||||
self.fill_remaining_theme_spells()
|
self.fill_remaining_theme_spells()
|
||||||
self.print_type_summary()
|
self.print_type_summary()
|
||||||
|
|
||||||
|
def add_spells_phase(self):
|
||||||
|
"""Public method for orchestration: delegates to add_non_creature_spells.
|
||||||
|
Use this as the main entry point for the spell addition phase in deck building.
|
||||||
|
"""
|
||||||
|
"""Public method for orchestration: delegates to add_non_creature_spells."""
|
||||||
|
return self.add_non_creature_spells()
|
||||||
|
|
|
@ -19,6 +19,9 @@ class ColorBalanceMixin:
|
||||||
# Color / pip computation helpers (cached)
|
# Color / pip computation helpers (cached)
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
def _compute_color_source_matrix(self) -> Dict[str, Dict[str,int]]:
|
def _compute_color_source_matrix(self) -> Dict[str, Dict[str,int]]:
|
||||||
|
"""Compute the color source matrix for the current deck library.
|
||||||
|
Returns a mapping of card names to color sources, cached for efficiency.
|
||||||
|
"""
|
||||||
if self._color_source_matrix_cache is not None and not self._color_source_cache_dirty:
|
if self._color_source_matrix_cache is not None and not self._color_source_cache_dirty:
|
||||||
return self._color_source_matrix_cache
|
return self._color_source_matrix_cache
|
||||||
matrix = bu.compute_color_source_matrix(self.card_library, getattr(self, '_full_cards_df', None))
|
matrix = bu.compute_color_source_matrix(self.card_library, getattr(self, '_full_cards_df', None))
|
||||||
|
@ -27,6 +30,9 @@ class ColorBalanceMixin:
|
||||||
return matrix
|
return matrix
|
||||||
|
|
||||||
def _compute_spell_pip_weights(self) -> Dict[str, float]:
|
def _compute_spell_pip_weights(self) -> Dict[str, float]:
|
||||||
|
"""Compute the spell pip weights for the current deck library.
|
||||||
|
Returns a mapping of color letters to pip weight, cached for efficiency.
|
||||||
|
"""
|
||||||
if self._spell_pip_weights_cache is not None and not self._spell_pip_cache_dirty:
|
if self._spell_pip_weights_cache is not None and not self._spell_pip_cache_dirty:
|
||||||
return self._spell_pip_weights_cache
|
return self._spell_pip_weights_cache
|
||||||
weights = bu.compute_spell_pip_weights(self.card_library, self.color_identity)
|
weights = bu.compute_spell_pip_weights(self.card_library, self.color_identity)
|
||||||
|
@ -35,6 +41,9 @@ class ColorBalanceMixin:
|
||||||
return weights
|
return weights
|
||||||
|
|
||||||
def _current_color_source_counts(self) -> Dict[str,int]:
|
def _current_color_source_counts(self) -> Dict[str,int]:
|
||||||
|
"""Return the current counts of color sources in the deck library.
|
||||||
|
Uses the color source matrix to aggregate counts for each color.
|
||||||
|
"""
|
||||||
matrix = self._compute_color_source_matrix()
|
matrix = self._compute_color_source_matrix()
|
||||||
counts = {c:0 for c in ['W','U','B','R','G']}
|
counts = {c:0 for c in ['W','U','B','R','G']}
|
||||||
for name, colors in matrix.items():
|
for name, colors in matrix.items():
|
||||||
|
@ -48,12 +57,18 @@ class ColorBalanceMixin:
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Post-spell land adjustment & basic rebalance
|
# Post-spell land adjustment & basic rebalance
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
def post_spell_land_adjust(self,
|
def post_spell_land_adjust(
|
||||||
|
self,
|
||||||
pip_weights: Optional[Dict[str, float]] = None,
|
pip_weights: Optional[Dict[str, float]] = None,
|
||||||
color_shortfall_threshold: float = 0.15,
|
color_shortfall_threshold: float = 0.15,
|
||||||
perform_swaps: bool = True,
|
perform_swaps: bool = True,
|
||||||
max_swaps: int = 5,
|
max_swaps: int = 5,
|
||||||
rebalance_basics: bool = True): # noqa: C901
|
rebalance_basics: bool = True
|
||||||
|
):
|
||||||
|
"""Post-spell land adjustment and basic rebalance.
|
||||||
|
Analyzes color deficits after spell addition and optionally swaps lands and rebalances basics
|
||||||
|
to better align mana sources with spell pip demand.
|
||||||
|
"""
|
||||||
if pip_weights is None:
|
if pip_weights is None:
|
||||||
pip_weights = self._compute_spell_pip_weights()
|
pip_weights = self._compute_spell_pip_weights()
|
||||||
if self.color_source_matrix_baseline is None:
|
if self.color_source_matrix_baseline is None:
|
||||||
|
|
|
@ -15,9 +15,21 @@ except Exception: # pragma: no cover
|
||||||
PrettyTable = None # type: ignore
|
PrettyTable = None # type: ignore
|
||||||
|
|
||||||
class ReportingMixin:
|
class ReportingMixin:
|
||||||
|
def run_reporting_phase(self):
|
||||||
|
"""Public method for orchestration: delegates to print_type_summary and print_card_library.
|
||||||
|
def export_decklist_text(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
||||||
|
def export_decklist_text(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
||||||
|
Use this as the main entry point for the reporting phase in deck building.
|
||||||
|
"""
|
||||||
|
"""Public method for orchestration: delegates to print_type_summary and print_card_library."""
|
||||||
|
self.print_type_summary()
|
||||||
|
self.print_card_library(table=True)
|
||||||
"""Phase 6: Reporting, summaries, and export helpers."""
|
"""Phase 6: Reporting, summaries, and export helpers."""
|
||||||
|
|
||||||
def _wrap_cell(self, text: str, width: int = 28) -> str:
|
def _wrap_cell(self, text: str, width: int = 28) -> str:
|
||||||
|
"""Wraps a string to a specified width for table display.
|
||||||
|
Used for pretty-printing card names, roles, and tags in tabular output.
|
||||||
|
"""
|
||||||
words = text.split()
|
words = text.split()
|
||||||
lines: List[str] = []
|
lines: List[str] = []
|
||||||
current_line = []
|
current_line = []
|
||||||
|
@ -35,6 +47,9 @@ class ReportingMixin:
|
||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
def print_type_summary(self):
|
def print_type_summary(self):
|
||||||
|
"""Prints a summary of card types and their counts in the current deck library.
|
||||||
|
Displays type distribution and percentage breakdown.
|
||||||
|
"""
|
||||||
type_counts: Dict[str,int] = {}
|
type_counts: Dict[str,int] = {}
|
||||||
for name, info in self.card_library.items():
|
for name, info in self.card_library.items():
|
||||||
ctype = info.get('Type', 'Unknown')
|
ctype = info.get('Type', 'Unknown')
|
||||||
|
@ -44,7 +59,12 @@ class ReportingMixin:
|
||||||
self.output_func("\nType Summary:")
|
self.output_func("\nType Summary:")
|
||||||
for t, c in sorted(type_counts.items(), key=lambda kv: (-kv[1], kv[0])):
|
for t, c in sorted(type_counts.items(), key=lambda kv: (-kv[1], kv[0])):
|
||||||
self.output_func(f" {t:<15} {c:>3} ({(c/total_cards*100 if total_cards else 0):5.1f}%)")
|
self.output_func(f" {t:<15} {c:>3} ({(c/total_cards*100 if total_cards else 0):5.1f}%)")
|
||||||
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None) -> str:
|
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
||||||
|
"""Export current decklist to CSV (enriched).
|
||||||
|
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
|
||||||
|
Included columns: Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, Tags, Text.
|
||||||
|
Falls back gracefully if snapshot rows missing.
|
||||||
|
"""
|
||||||
"""Export current decklist to CSV (enriched).
|
"""Export current decklist to CSV (enriched).
|
||||||
|
|
||||||
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
|
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
|
||||||
|
@ -55,9 +75,15 @@ class ReportingMixin:
|
||||||
os.makedirs(directory, exist_ok=True)
|
os.makedirs(directory, exist_ok=True)
|
||||||
if filename is None:
|
if filename is None:
|
||||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||||
cmdr_first = cmdr.split()[0] if cmdr else 'deck'
|
if isinstance(cmdr, str) and cmdr:
|
||||||
|
cmdr_first = cmdr.split()[0]
|
||||||
|
else:
|
||||||
|
cmdr_first = 'deck'
|
||||||
theme = getattr(self, 'primary_tag', None) or (self.selected_tags[0] if getattr(self, 'selected_tags', []) else None)
|
theme = getattr(self, 'primary_tag', None) or (self.selected_tags[0] if getattr(self, 'selected_tags', []) else None)
|
||||||
theme_first = str(theme).split()[0] if theme else 'notheme'
|
if isinstance(theme, str) and theme:
|
||||||
|
theme_first = theme.split()[0]
|
||||||
|
else:
|
||||||
|
theme_first = 'notheme'
|
||||||
def _slug(s: str) -> str:
|
def _slug(s: str) -> str:
|
||||||
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
|
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
|
||||||
return s2 or 'x'
|
return s2 or 'x'
|
||||||
|
@ -194,12 +220,15 @@ class ReportingMixin:
|
||||||
try: # pragma: no cover - sidecar convenience
|
try: # pragma: no cover - sidecar convenience
|
||||||
stem = os.path.splitext(os.path.basename(fname))[0]
|
stem = os.path.splitext(os.path.basename(fname))[0]
|
||||||
# Always overwrite sidecar to reflect latest deck state
|
# Always overwrite sidecar to reflect latest deck state
|
||||||
self.export_decklist_text(directory=directory, filename=stem + '.txt') # type: ignore[attr-defined]
|
self.export_decklist_text(directory=directory, filename=stem + '.txt', suppress_output=True) # type: ignore[attr-defined]
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Plaintext sidecar export failed (non-fatal)")
|
logger.warning("Plaintext sidecar export failed (non-fatal)")
|
||||||
return fname
|
return fname
|
||||||
|
|
||||||
def export_decklist_text(self, directory: str = 'deck_files', filename: str | None = None) -> str:
|
def export_decklist_text(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
||||||
|
"""Export a simple plaintext list: one line per unique card -> "[Count] [Card Name]".
|
||||||
|
Naming mirrors CSV export (same stem, .txt extension). Sorting follows same precedence.
|
||||||
|
"""
|
||||||
"""Export a simple plaintext list: one line per unique card -> "[Count] [Card Name]".
|
"""Export a simple plaintext list: one line per unique card -> "[Count] [Card Name]".
|
||||||
|
|
||||||
Naming mirrors CSV export (same stem, .txt extension). Sorting follows same
|
Naming mirrors CSV export (same stem, .txt extension). Sorting follows same
|
||||||
|
@ -209,9 +238,15 @@ class ReportingMixin:
|
||||||
# Derive base filename logic (shared with CSV exporter) – intentionally duplicated to avoid refactor risk.
|
# Derive base filename logic (shared with CSV exporter) – intentionally duplicated to avoid refactor risk.
|
||||||
if filename is None:
|
if filename is None:
|
||||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||||
cmdr_first = cmdr.split()[0] if cmdr else 'deck'
|
if isinstance(cmdr, str) and cmdr:
|
||||||
|
cmdr_first = cmdr.split()[0]
|
||||||
|
else:
|
||||||
|
cmdr_first = 'deck'
|
||||||
theme = getattr(self, 'primary_tag', None) or (self.selected_tags[0] if getattr(self, 'selected_tags', []) else None)
|
theme = getattr(self, 'primary_tag', None) or (self.selected_tags[0] if getattr(self, 'selected_tags', []) else None)
|
||||||
theme_first = str(theme).split()[0] if theme else 'notheme'
|
if isinstance(theme, str) and theme:
|
||||||
|
theme_first = theme.split()[0]
|
||||||
|
else:
|
||||||
|
theme_first = 'notheme'
|
||||||
def _slug(s: str) -> str:
|
def _slug(s: str) -> str:
|
||||||
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
|
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
|
||||||
return s2 or 'x'
|
return s2 or 'x'
|
||||||
|
@ -278,30 +313,13 @@ class ReportingMixin:
|
||||||
with open(path, 'w', encoding='utf-8') as f:
|
with open(path, 'w', encoding='utf-8') as f:
|
||||||
for _, name, count in sortable:
|
for _, name, count in sortable:
|
||||||
f.write(f"{count} {name}\n")
|
f.write(f"{count} {name}\n")
|
||||||
|
if not suppress_output:
|
||||||
self.output_func(f"Plaintext deck list exported to {path}")
|
self.output_func(f"Plaintext deck list exported to {path}")
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def print_card_library(self, table: bool = True): # noqa: C901
|
def print_card_library(self, table: bool = True):
|
||||||
if table and PrettyTable is None:
|
"""Prints the current card library in either plain or tabular format.
|
||||||
table = False
|
Uses PrettyTable if available, otherwise prints a simple list.
|
||||||
if not table:
|
"""
|
||||||
self.output_func("\nCard Library:")
|
# Card library printout suppressed; use CSV and text export for card list.
|
||||||
for name, info in sorted(self.card_library.items()):
|
pass
|
||||||
self.output_func(f" {info.get('Count',1)}x {name} [{info.get('Type','')}] ({info.get('Role','')})")
|
|
||||||
return
|
|
||||||
# PrettyTable mode
|
|
||||||
pt = PrettyTable()
|
|
||||||
pt.field_names = ["Name","Count","Type","CMC","Role","Tags","Notes"]
|
|
||||||
pt.align = 'l'
|
|
||||||
for name, info in sorted(self.card_library.items()):
|
|
||||||
pt.add_row([
|
|
||||||
self._wrap_cell(name),
|
|
||||||
info.get('Count',1),
|
|
||||||
info.get('Type',''),
|
|
||||||
info.get('CMC',''),
|
|
||||||
self._wrap_cell(info.get('Role','')),
|
|
||||||
self._wrap_cell(','.join(info.get('Tags',[]) or [])),
|
|
||||||
self._wrap_cell(info.get('SourceNotes',''))
|
|
||||||
])
|
|
||||||
self.output_func("\nCard Library (tabular):")
|
|
||||||
self.output_func(pt.get_string())
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue