Finished modularization with assistance from Github copilot

This commit is contained in:
mwisnowski 2025-08-21 08:40:31 -07:00
parent d9b56d8e12
commit 0135eeeb3d
6 changed files with 202 additions and 139 deletions

View file

@ -49,19 +49,67 @@ if not any(isinstance(h, logging_util.logging.StreamHandler) for h in logger.han
## Phase 0 extraction: BracketDefinition & BRACKET_DEFINITIONS now imported
@dataclass
class DeckBuilder(CommanderSelectionMixin,
LandBasicsMixin,
LandStaplesMixin,
LandKindredMixin,
LandFetchMixin,
LandDualsMixin,
LandTripleMixin,
LandMiscUtilityMixin,
LandOptimizationMixin,
CreatureAdditionMixin,
SpellAdditionMixin,
ColorBalanceMixin,
ReportingMixin):
class DeckBuilder(
CommanderSelectionMixin,
LandBasicsMixin,
LandStaplesMixin,
LandKindredMixin,
LandFetchMixin,
LandDualsMixin,
LandTripleMixin,
LandMiscUtilityMixin,
LandOptimizationMixin,
CreatureAdditionMixin,
SpellAdditionMixin,
ColorBalanceMixin,
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_name: str = ""
commander_row: Optional[pd.Series] = None
@ -135,93 +183,22 @@ class DeckBuilder(CommanderSelectionMixin,
self._original_output_func = self.output_func
def _wrapped(msg: str):
try:
# Collapse excessive blank lines for log readability, but keep printing original
log_msg = msg.rstrip()
if log_msg:
logger.info(log_msg)
except Exception:
pass
# Collapse excessive blank lines for log readability, but keep printing original
log_msg = msg.rstrip()
if log_msg:
logger.info(log_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 _log_debug(self, msg: str):
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):
m = getattr(self, f"run_land_step{step}", None)
if callable(m):
logger.info(f"Land Step {step}: begin")
m()
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}")
def _run_land_build_steps(self):
"""Run all land build steps (1-8) in order, logging progress."""
for step in range(1, 9):
m = getattr(self, f"run_land_step{step}", None)
if callable(m):
logger.info(f"Land Step {step}: begin")
m()
logger.info(f"Land Step {step}: complete (current land count {self._current_land_count() if hasattr(self, '_current_land_count') else 'n/a'})")
# ---------------------------
# RNG Initialization

View file

@ -19,7 +19,11 @@ class CreatureAdditionMixin:
- Avoid duplicating the commander
- 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)
if df is None or df.empty:
self.output_func("Card pool not loaded; cannot add creatures.")
@ -179,3 +183,10 @@ class CreatureAdditionMixin:
else:
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 ''}")
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()

View file

@ -120,7 +120,11 @@ class SpellAdditionMixin:
# ---------------------------
# 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)
if target <= 0 or self._combined_cards_df is None:
return
@ -179,6 +183,10 @@ class SpellAdditionMixin:
# Board Wipes
# ---------------------------
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)
if target <= 0 or self._combined_cards_df is None:
return
@ -232,7 +240,11 @@ class SpellAdditionMixin:
# ---------------------------
# 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)
if total_target <= 0 or self._combined_cards_df is None:
return
@ -321,6 +333,10 @@ class SpellAdditionMixin:
# Protection
# ---------------------------
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)
if target <= 0 or self._combined_cards_df is None:
return
@ -371,7 +387,11 @@ class SpellAdditionMixin:
# ---------------------------
# 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())
remaining = 100 - total_cards
if remaining <= 0:
@ -603,6 +623,9 @@ class SpellAdditionMixin:
# Orchestrator
# ---------------------------
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."""
self.add_ramp()
self.add_removal()
@ -611,3 +634,11 @@ class SpellAdditionMixin:
self.add_protection()
self.fill_remaining_theme_spells()
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()

View file

@ -19,6 +19,9 @@ class ColorBalanceMixin:
# Color / pip computation helpers (cached)
# ---------------------------
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:
return self._color_source_matrix_cache
matrix = bu.compute_color_source_matrix(self.card_library, getattr(self, '_full_cards_df', None))
@ -27,6 +30,9 @@ class ColorBalanceMixin:
return matrix
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:
return self._spell_pip_weights_cache
weights = bu.compute_spell_pip_weights(self.card_library, self.color_identity)
@ -35,6 +41,9 @@ class ColorBalanceMixin:
return weights
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()
counts = {c:0 for c in ['W','U','B','R','G']}
for name, colors in matrix.items():
@ -48,12 +57,18 @@ class ColorBalanceMixin:
# ---------------------------
# Post-spell land adjustment & basic rebalance
# ---------------------------
def post_spell_land_adjust(self,
pip_weights: Optional[Dict[str,float]] = None,
color_shortfall_threshold: float = 0.15,
perform_swaps: bool = True,
max_swaps: int = 5,
rebalance_basics: bool = True): # noqa: C901
def post_spell_land_adjust(
self,
pip_weights: Optional[Dict[str, float]] = None,
color_shortfall_threshold: float = 0.15,
perform_swaps: bool = True,
max_swaps: int = 5,
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:
pip_weights = self._compute_spell_pip_weights()
if self.color_source_matrix_baseline is None:

View file

@ -15,9 +15,21 @@ except Exception: # pragma: no cover
PrettyTable = None # type: ignore
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."""
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()
lines: List[str] = []
current_line = []
@ -35,6 +47,9 @@ class ReportingMixin:
return '\n'.join(lines)
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] = {}
for name, info in self.card_library.items():
ctype = info.get('Type', 'Unknown')
@ -44,7 +59,12 @@ class ReportingMixin:
self.output_func("\nType Summary:")
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}%)")
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).
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
@ -55,9 +75,15 @@ class ReportingMixin:
os.makedirs(directory, exist_ok=True)
if filename is None:
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_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:
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
return s2 or 'x'
@ -194,12 +220,15 @@ class ReportingMixin:
try: # pragma: no cover - sidecar convenience
stem = os.path.splitext(os.path.basename(fname))[0]
# 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:
logger.warning("Plaintext sidecar export failed (non-fatal)")
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]".
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.
if filename is None:
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_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:
s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s)
return s2 or 'x'
@ -278,30 +313,13 @@ class ReportingMixin:
with open(path, 'w', encoding='utf-8') as f:
for _, name, count in sortable:
f.write(f"{count} {name}\n")
self.output_func(f"Plaintext deck list exported to {path}")
if not suppress_output:
self.output_func(f"Plaintext deck list exported to {path}")
return path
def print_card_library(self, table: bool = True): # noqa: C901
if table and PrettyTable is None:
table = False
if not table:
self.output_func("\nCard Library:")
for name, info in sorted(self.card_library.items()):
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())
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.
"""
# Card library printout suppressed; use CSV and text export for card list.
pass