diff --git a/code/__init__.py b/code/__init__.py new file mode 100644 index 0000000..b4309c0 --- /dev/null +++ b/code/__init__.py @@ -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__ = [] diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index a3f30c8..4c0effe 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -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/_.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 diff --git a/code/deck_builder/phases/phase3_creatures.py b/code/deck_builder/phases/phase3_creatures.py index 7895189..588c7c2 100644 --- a/code/deck_builder/phases/phase3_creatures.py +++ b/code/deck_builder/phases/phase3_creatures.py @@ -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() diff --git a/code/deck_builder/phases/phase4_spells.py b/code/deck_builder/phases/phase4_spells.py index 21dc5e7..89d1b4f 100644 --- a/code/deck_builder/phases/phase4_spells.py +++ b/code/deck_builder/phases/phase4_spells.py @@ -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() + \ No newline at end of file diff --git a/code/deck_builder/phases/phase5_color_balance.py b/code/deck_builder/phases/phase5_color_balance.py index 630fe03..45971a9 100644 --- a/code/deck_builder/phases/phase5_color_balance.py +++ b/code/deck_builder/phases/phase5_color_balance.py @@ -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: diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index 54199c8..b46b0cd 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -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