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

@ -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