from __future__ import annotations from typing import Any, Dict, List import csv import os import datetime as _dt import re as _re import logging_util from ..summary_telemetry import record_land_summary, record_theme_summary, record_partner_summary from ..color_identity_utils import normalize_colors, canon_color_code, color_label_from_code from ..shared_copy import build_land_headline, dfc_card_note logger = logging_util.logging.getLogger(__name__) try: from prettytable import PrettyTable 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) def get_commander_export_metadata(self) -> Dict[str, Any]: """Return metadata describing the active commander configuration for export surfaces.""" def _clean(value: object) -> str: try: text = str(value).strip() except Exception: text = "" return text metadata: Dict[str, Any] = { "primary_commander": None, "secondary_commander": None, "commander_names": [], "partner_mode": None, "color_identity": [], } combined = getattr(self, 'combined_commander', None) commander_names: list[str] = [] primary_name = None secondary_name = None if combined is not None: primary_name = _clean(getattr(combined, 'primary_name', '')) or None secondary_name = _clean(getattr(combined, 'secondary_name', '')) or None partner_mode_obj = getattr(combined, 'partner_mode', None) partner_mode_val = getattr(partner_mode_obj, 'value', None) if isinstance(partner_mode_val, str) and partner_mode_val.strip(): metadata["partner_mode"] = partner_mode_val.strip() elif isinstance(partner_mode_obj, str) and partner_mode_obj.strip(): metadata["partner_mode"] = partner_mode_obj.strip() if primary_name: commander_names.append(primary_name) if secondary_name and all(secondary_name.casefold() != n.casefold() for n in commander_names): commander_names.append(secondary_name) combined_identity_raw = list(getattr(combined, 'color_identity', []) or []) combined_colors = normalize_colors(combined_identity_raw) primary_colors = normalize_colors(getattr(combined, 'primary_color_identity', ())) secondary_colors = normalize_colors(getattr(combined, 'secondary_color_identity', ())) color_code = getattr(combined, 'color_code', '') or canon_color_code(combined_identity_raw) color_label = getattr(combined, 'color_label', '') or color_label_from_code(color_code) mode_lower = (metadata["partner_mode"] or "").lower() if metadata.get("partner_mode") else "" if mode_lower == "background": secondary_role = "background" elif mode_lower == "doctor_companion": secondary_role = "companion" elif mode_lower == "partner_with": secondary_role = "partner_with" elif mode_lower == "partner": secondary_role = "partner" else: secondary_role = "secondary" secondary_role_label_map = { "background": "Background", "companion": "Doctor pairing", "partner_with": "Partner With", "partner": "Partner commander", } secondary_role_label = secondary_role_label_map.get(secondary_role, "Partner commander") color_sources: list[Dict[str, Any]] = [] for color in combined_colors: providers: list[Dict[str, Any]] = [] if primary_name and color in primary_colors: providers.append({"name": primary_name, "role": "primary"}) if secondary_name and color in secondary_colors: providers.append({"name": secondary_name, "role": secondary_role}) if not providers and primary_name: providers.append({"name": primary_name, "role": "primary"}) color_sources.append({"color": color, "providers": providers}) added_colors = [c for c in combined_colors if c not in primary_colors] removed_colors = [c for c in primary_colors if c not in combined_colors] combined_payload = { "primary_name": primary_name, "secondary_name": secondary_name, "partner_mode": metadata["partner_mode"], "color_identity": combined_identity_raw, "theme_tags": list(getattr(combined, 'theme_tags', []) or []), "raw_tags_primary": list(getattr(combined, 'raw_tags_primary', []) or []), "raw_tags_secondary": list(getattr(combined, 'raw_tags_secondary', []) or []), "warnings": list(getattr(combined, 'warnings', []) or []), "color_code": color_code, "color_label": color_label, "primary_color_identity": primary_colors, "secondary_color_identity": secondary_colors, "secondary_role": secondary_role, "secondary_role_label": secondary_role_label, "color_sources": color_sources, "color_delta": { "added": added_colors, "removed": removed_colors, "primary": primary_colors, "secondary": secondary_colors, }, } metadata["combined_commander"] = combined_payload else: primary_attr = _clean(getattr(self, 'commander_name', '') or getattr(self, 'commander', '')) if primary_attr: primary_name = primary_attr commander_names.append(primary_attr) secondary_attr = _clean(getattr(self, 'secondary_commander', '')) if secondary_attr and all(secondary_attr.casefold() != n.casefold() for n in commander_names): secondary_name = secondary_attr commander_names.append(secondary_attr) partner_mode_attr = getattr(self, 'partner_mode', None) partner_mode_val = getattr(partner_mode_attr, 'value', None) if isinstance(partner_mode_val, str) and partner_mode_val.strip(): metadata["partner_mode"] = partner_mode_val.strip() elif isinstance(partner_mode_attr, str) and partner_mode_attr.strip(): metadata["partner_mode"] = partner_mode_attr.strip() metadata["primary_commander"] = primary_name metadata["secondary_commander"] = secondary_name metadata["commander_names"] = commander_names if metadata["partner_mode"]: metadata["partner_mode"] = metadata["partner_mode"].lower() # Prefer combined color identity when available color_source = None if combined is not None: color_source = getattr(combined, 'color_identity', None) if not color_source: color_source = getattr(self, 'combined_color_identity', None) if not color_source: color_source = getattr(self, 'color_identity', None) if color_source: metadata["color_identity"] = [str(c).strip().upper() for c in color_source if str(c).strip()] return metadata """Phase 6: Reporting, summaries, and export helpers.""" def enforce_and_reexport(self, base_stem: str | None = None, mode: str = "prompt") -> dict: """Run bracket enforcement, then re-export CSV/TXT and recompute compliance. mode: 'prompt' for CLI interactive; 'auto' for headless/web. Returns the final compliance report dict. """ try: # Lazy import to avoid cycles from deck_builder.enforcement import enforce_bracket_compliance except Exception: self.output_func("Enforcement module unavailable.") return {} # Enforce report = enforce_bracket_compliance(self, mode=mode) # If enforcement removed cards without enough replacements, top up to 100 using theme filler try: total_cards = 0 for _n, _e in getattr(self, 'card_library', {}).items(): try: total_cards += int(_e.get('Count', 1)) except Exception: total_cards += 1 if int(total_cards) < 100 and hasattr(self, 'fill_remaining_theme_spells'): before = int(total_cards) try: self.fill_remaining_theme_spells() except Exception: pass # Recompute after filler try: total_cards = 0 for _n, _e in getattr(self, 'card_library', {}).items(): try: total_cards += int(_e.get('Count', 1)) except Exception: total_cards += 1 except Exception: total_cards = before try: self.output_func(f"Topped up deck to {total_cards}/100 after enforcement.") except Exception: pass except Exception: pass # Print what changed try: enf = report.get('enforcement') or {} removed = list(enf.get('removed') or []) added = list(enf.get('added') or []) if removed or added: self.output_func("\nEnforcement Summary (swaps):") if removed: self.output_func("Removed:") for n in removed: self.output_func(f" - {n}") if added: self.output_func("Added:") for n in added: self.output_func(f" + {n}") except Exception: pass # Re-export using same base, if provided try: import os as _os import json as _json if isinstance(base_stem, str) and base_stem.strip(): # Mirror CSV/TXT export naming csv_name = base_stem + ".csv" txt_name = base_stem + ".txt" # Overwrite exports with updated library self.export_decklist_csv(directory='deck_files', filename=csv_name, suppress_output=True) self.export_decklist_text(directory='deck_files', filename=txt_name, suppress_output=True) # Re-export the JSON config to reflect any changes from enforcement json_name = base_stem + ".json" self.export_run_config_json(directory='config', filename=json_name, suppress_output=True) # Recompute and write compliance next to them self.compute_and_print_compliance(base_stem=base_stem) # Inject enforcement details into the saved compliance JSON for UI transparency comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json") try: if _os.path.exists(comp_path) and isinstance(report, dict) and report.get('enforcement'): with open(comp_path, 'r', encoding='utf-8') as _f: comp_obj = _json.load(_f) comp_obj['enforcement'] = report.get('enforcement') with open(comp_path, 'w', encoding='utf-8') as _f: _json.dump(comp_obj, _f, indent=2) except Exception: pass else: # Fall back to default export flow csv_path = self.export_decklist_csv() try: base, _ = _os.path.splitext(csv_path) base_only = _os.path.basename(base) except Exception: base_only = None self.export_decklist_text(filename=(base_only + '.txt') if base_only else None) # Re-export JSON config after enforcement changes if base_only: self.export_run_config_json(directory='config', filename=base_only + '.json', suppress_output=True) if base_only: self.compute_and_print_compliance(base_stem=base_only) # Inject enforcement into written JSON as above try: comp_path = _os.path.join('deck_files', f"{base_only}_compliance.json") if _os.path.exists(comp_path) and isinstance(report, dict) and report.get('enforcement'): with open(comp_path, 'r', encoding='utf-8') as _f: comp_obj = _json.load(_f) comp_obj['enforcement'] = report.get('enforcement') with open(comp_path, 'w', encoding='utf-8') as _f: _json.dump(comp_obj, _f, indent=2) except Exception: pass except Exception: pass return report def compute_and_print_compliance(self, base_stem: str | None = None) -> dict: """Compute bracket compliance, print a compact summary, and optionally write a JSON report. If base_stem is provided, writes deck_files/{base_stem}_compliance.json. Returns the compliance report dict. """ try: # Late import to avoid circulars in some environments from deck_builder.brackets_compliance import evaluate_deck except Exception: self.output_func("Bracket compliance module unavailable.") return {} try: bracket_key = str(getattr(self, 'bracket_name', '') or getattr(self, 'bracket_level', 'core')).lower() commander = getattr(self, 'commander_name', None) report = evaluate_deck(self.card_library, commander_name=commander, bracket=bracket_key) except Exception as e: self.output_func(f"Compliance evaluation failed: {e}") return {} # Print concise summary try: self.output_func("\nBracket Compliance:") self.output_func(f" Overall: {report.get('overall', 'PASS')}") cats = report.get('categories', {}) or {} order = [ ('game_changers', 'Game Changers'), ('mass_land_denial', 'Mass Land Denial'), ('extra_turns', 'Extra Turns'), ('tutors_nonland', 'Nonland Tutors'), ('two_card_combos', 'Two-Card Combos'), ] for key, label in order: c = cats.get(key, {}) or {} cnt = int(c.get('count', 0) or 0) lim = c.get('limit') status = str(c.get('status') or 'PASS') lim_txt = ('Unlimited' if lim is None else str(int(lim))) self.output_func(f" {label:<16} {cnt} / {lim_txt} [{status}]") except Exception: pass # Optionally write JSON report next to exports if isinstance(base_stem, str) and base_stem.strip(): try: import os as _os _os.makedirs('deck_files', exist_ok=True) path = _os.path.join('deck_files', f"{base_stem}_compliance.json") import json as _json with open(path, 'w', encoding='utf-8') as f: _json.dump(report, f, indent=2) self.output_func(f"Compliance report saved to {path}") except Exception: pass return report 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 = [] current_len = 0 for w in words: if current_len + len(w) + (1 if current_line else 0) > width: lines.append(' '.join(current_line)) current_line = [w] current_len = len(w) else: current_line.append(w) current_len += len(w) + (1 if len(current_line) > 1 else 0) if current_line: lines.append(' '.join(current_line)) return '\n'.join(lines) def print_type_summary(self): """Print a type/category distribution for the current deck library. Uses the stored 'Card Type' when available; otherwise enriches from the loaded card snapshot. Categories mirror export classification. """ # Build a quick lookup from the loaded dataset to enrich type lines full_df = getattr(self, '_full_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None) snapshot = full_df if full_df is not None else combined_df row_lookup: Dict[str, Any] = {} if snapshot is not None and hasattr(snapshot, 'empty') and not snapshot.empty and 'name' in snapshot.columns: for _, r in snapshot.iterrows(): nm = str(r.get('name')) if nm not in row_lookup: row_lookup[nm] = r # Category precedence (purely for stable sorted output) precedence_order = [ 'Commander', 'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land', 'Other' ] precedence_index = {k: i for i, k in enumerate(precedence_order)} commander_name = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '' def classify(primary_type_line: str, card_name: str) -> str: if commander_name and card_name == commander_name: return 'Commander' tl = (primary_type_line or '').lower() if 'battle' in tl: return 'Battle' if 'planeswalker' in tl: return 'Planeswalker' if 'creature' in tl: return 'Creature' if 'instant' in tl: return 'Instant' if 'sorcery' in tl: return 'Sorcery' if 'artifact' in tl: return 'Artifact' if 'enchantment' in tl: return 'Enchantment' if 'land' in tl: return 'Land' return 'Other' # Count by classified category cat_counts: Dict[str, int] = {} for name, info in self.card_library.items(): base_type = info.get('Card Type') or info.get('Type', '') if not base_type: row = row_lookup.get(name) if row is not None: base_type = row.get('type', row.get('type_line', '')) or '' category = classify(base_type, name) cnt = int(info.get('Count', 1)) cat_counts[category] = cat_counts.get(category, 0) + cnt total_cards = sum(cat_counts.values()) self.output_func("\nType Summary:") for cat, c in sorted(cat_counts.items(), key=lambda kv: (precedence_index.get(kv[0], 999), -kv[1], kv[0])): pct = (c / total_cards * 100) if total_cards else 0.0 self.output_func(f" {cat:<15} {c:>3} ({pct:5.1f}%)") # Surface land vs. MDFC counts for CLI users to mirror web summary copy try: summary = self.build_deck_summary() except Exception: summary = None if isinstance(summary, dict): land_summary = summary.get('land_summary') or {} if isinstance(land_summary, dict) and land_summary: traditional = int(land_summary.get('traditional', 0)) dfc_bonus = int(land_summary.get('dfc_lands', 0)) with_dfc = int(land_summary.get('with_dfc', traditional + dfc_bonus)) headline = land_summary.get('headline') if not headline: headline = build_land_headline(traditional, dfc_bonus, with_dfc) self.output_func(f" {headline}") dfc_cards = land_summary.get('dfc_cards') or [] if isinstance(dfc_cards, list) and dfc_cards: self.output_func(" MDFC sources:") for entry in dfc_cards: try: name = str(entry.get('name', '')) count = int(entry.get('count', 1)) except Exception: name, count = str(entry.get('name', '')), 1 colors = entry.get('colors') or [] colors_txt = ', '.join(colors) if colors else '-' adds_extra = bool(entry.get('adds_extra_land') or entry.get('counts_as_extra')) note = entry.get('note') or dfc_card_note(adds_extra) self.output_func(f" - {name} ×{count} ({colors_txt}) — {note}") # --------------------------- # Structured deck summary for UI (types, pips, sources, curve) # --------------------------- def build_deck_summary(self) -> dict: """Return a structured summary of the finished deck for UI rendering. Structure: { 'type_breakdown': { 'counts': { type: count, ... }, 'order': [sorted types by precedence], 'cards': { type: [ {name, count}, ... ] }, 'total': int }, 'pip_distribution': { 'counts': { 'W': n, 'U': n, 'B': n, 'R': n, 'G': n }, 'weights': { 'W': 0-1, ... }, # normalized weights (may not sum exactly to 1 due to rounding) }, 'mana_generation': { 'W': n, 'U': n, 'B': n, 'R': n, 'G': n, 'total_sources': n }, 'mana_curve': { '0': n, '1': n, '2': n, '3': n, '4': n, '5': n, '6+': n, 'total_spells': n } } """ # Build lookup to enrich type and mana values full_df = getattr(self, '_full_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None) snapshot = full_df if full_df is not None else combined_df row_lookup: Dict[str, Any] = {} if snapshot is not None and not getattr(snapshot, 'empty', True) and 'name' in snapshot.columns: for _, r in snapshot.iterrows(): nm = str(r.get('name')) if nm and nm not in row_lookup: row_lookup[nm] = r # Category classification (reuse export logic) precedence_order = [ 'Commander', 'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land', 'Other' ] precedence_index = {k: i for i, k in enumerate(precedence_order)} commander_name = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '' def classify(primary_type_line: str, card_name: str) -> str: if commander_name and card_name == commander_name: return 'Commander' tl = (primary_type_line or '').lower() if 'battle' in tl: return 'Battle' if 'planeswalker' in tl: return 'Planeswalker' if 'creature' in tl: return 'Creature' if 'instant' in tl: return 'Instant' if 'sorcery' in tl: return 'Sorcery' if 'artifact' in tl: return 'Artifact' if 'enchantment' in tl: return 'Enchantment' if 'land' in tl: return 'Land' return 'Other' builder_utils_module = None try: from deck_builder import builder_utils as _builder_utils builder_utils_module = _builder_utils color_matrix = builder_utils_module.compute_color_source_matrix(self.card_library, full_df) except Exception: color_matrix = {} dfc_land_lookup: Dict[str, Dict[str, Any]] = {} if color_matrix: for name, flags in color_matrix.items(): if not bool(flags.get('_dfc_land')): continue counts_as_extra = bool(flags.get('_dfc_counts_as_extra')) note_text = dfc_card_note(counts_as_extra) card_colors = [color for color in ('W', 'U', 'B', 'R', 'G', 'C') if flags.get(color)] faces_meta: list[Dict[str, Any]] = [] layout_val = None if builder_utils_module is not None: try: mf_info = builder_utils_module.multi_face_land_info(name) except Exception: mf_info = {} faces_meta = list(mf_info.get('faces', [])) if isinstance(mf_info, dict) else [] layout_val = mf_info.get('layout') if isinstance(mf_info, dict) else None # M9: If no colors found from mana production, try extracting from face metadata if not card_colors and isinstance(mf_info, dict): card_colors = list(mf_info.get('colors', [])) dfc_land_lookup[name] = { 'adds_extra_land': counts_as_extra, 'counts_as_land': not counts_as_extra, 'note': note_text, 'colors': card_colors, 'faces': faces_meta, 'layout': layout_val, } else: color_matrix = {} # Type breakdown (counts and per-type card lists) type_counts: Dict[str, int] = {} type_cards: Dict[str, list] = {} total_cards = 0 for name, info in self.card_library.items(): # Exclude commander from type breakdown per UI preference if commander_name and name == commander_name: continue cnt = int(info.get('Count', 1)) base_type = info.get('Card Type') or info.get('Type', '') if not base_type: row = row_lookup.get(name) if row is not None: base_type = row.get('type', row.get('type_line', '')) or '' category = classify(base_type, name) type_counts[category] = type_counts.get(category, 0) + cnt total_cards += cnt card_entry = { 'name': name, 'count': cnt, 'role': info.get('Role', '') or '', 'tags': list(info.get('Tags', []) or []), } dfc_meta = dfc_land_lookup.get(name) if dfc_meta: card_entry['dfc'] = True card_entry['dfc_land'] = True card_entry['dfc_adds_extra_land'] = bool(dfc_meta.get('adds_extra_land')) card_entry['dfc_counts_as_land'] = bool(dfc_meta.get('counts_as_land')) card_entry['dfc_note'] = dfc_meta.get('note', '') card_entry['dfc_colors'] = list(dfc_meta.get('colors', [])) card_entry['dfc_faces'] = list(dfc_meta.get('faces', [])) type_cards.setdefault(category, []).append(card_entry) # Sort cards within each type by name for cat, lst in type_cards.items(): lst.sort(key=lambda x: (x['name'].lower(), -int(x['count']))) type_order = sorted(type_counts.keys(), key=lambda k: precedence_index.get(k, 999)) # Track multi-face land contributions for later summary display dfc_details: list[dict] = [] dfc_extra_total = 0 # Pip distribution (counts and weights) for non-land spells only pip_counts = {c: 0 for c in ('W','U','B','R','G')} # For UI cross-highlighting: map color -> list of cards that have that color pip in their cost pip_cards: Dict[str, list] = {c: [] for c in ('W','U','B','R','G')} import re as _re_local total_pips = 0.0 for name, info in self.card_library.items(): ctype = str(info.get('Card Type', '')) if 'land' in ctype.lower(): continue mana_cost = info.get('Mana Cost') or info.get('mana_cost') or '' if not isinstance(mana_cost, str): continue # Track which colors appear for this card's mana cost for card listing colors_for_card = set() for match in _re_local.findall(r'\{([^}]+)\}', mana_cost): sym = match.upper() if len(sym) == 1 and sym in pip_counts: pip_counts[sym] += 1 total_pips += 1 colors_for_card.add(sym) elif '/' in sym: parts = [p for p in sym.split('/') if p in pip_counts] if parts: weight_each = 1 / len(parts) for p in parts: pip_counts[p] += weight_each total_pips += weight_each colors_for_card.add(p) elif sym.endswith('P') and len(sym) == 2: # e.g. WP (Phyrexian) -> treat as that color base = sym[0] if base in pip_counts: pip_counts[base] += 1 total_pips += 1 colors_for_card.add(base) if colors_for_card: cnt = int(info.get('Count', 1)) for c in colors_for_card: pip_cards[c].append({'name': name, 'count': cnt}) if total_pips <= 0: # Fallback to even distribution across color identity colors = [c for c in ('W','U','B','R','G') if c in (getattr(self, 'color_identity', []) or [])] if colors: share = 1 / len(colors) for c in colors: pip_counts[c] = share total_pips = 1.0 pip_weights = {c: (pip_counts[c] / total_pips if total_pips else 0.0) for c in pip_counts} # Mana generation from lands (color sources) matrix = color_matrix source_counts = {c: 0 for c in ('W','U','B','R','G','C')} # For UI cross-highlighting: color -> list of cards that produce that color (typically lands, possibly others) source_cards: Dict[str, list] = {c: [] for c in ('W','U','B','R','G','C')} for name, flags in matrix.items(): copies = int(self.card_library.get(name, {}).get('Count', 1)) is_dfc_land = bool(flags.get('_dfc_land')) counts_as_extra = bool(flags.get('_dfc_counts_as_extra')) dfc_meta = dfc_land_lookup.get(name) for c in source_counts.keys(): if int(flags.get(c, 0)): source_counts[c] += copies entry = {'name': name, 'count': copies, 'dfc': is_dfc_land} if dfc_meta: entry['dfc_note'] = dfc_meta.get('note', '') entry['dfc_adds_extra_land'] = bool(dfc_meta.get('adds_extra_land')) source_cards[c].append(entry) if is_dfc_land: card_colors = list(dfc_meta.get('colors', [])) if dfc_meta else [color for color in ('W','U','B','R','G','C') if flags.get(color)] note_text = dfc_meta.get('note') if dfc_meta else dfc_card_note(counts_as_extra) adds_extra = bool(dfc_meta.get('adds_extra_land')) if dfc_meta else counts_as_extra counts_as_land = bool(dfc_meta.get('counts_as_land')) if dfc_meta else not counts_as_extra faces_meta = list(dfc_meta.get('faces', [])) if dfc_meta else [] layout_val = dfc_meta.get('layout') if dfc_meta else None dfc_details.append({ 'name': name, 'count': copies, 'colors': card_colors, 'counts_as_land': counts_as_land, 'adds_extra_land': adds_extra, 'counts_as_extra': adds_extra, 'note': note_text, 'faces': faces_meta, 'layout': layout_val, }) # M9: Count ALL MDFC lands for land summary dfc_extra_total += copies total_sources = sum(source_counts.values()) traditional_lands = type_counts.get('Land', 0) # M9: dfc_extra_total now contains ALL MDFC lands, not just extras land_summary = { 'traditional': traditional_lands, 'dfc_lands': dfc_extra_total, # M9: Count of all MDFC lands 'with_dfc': traditional_lands + dfc_extra_total, 'dfc_cards': dfc_details, 'headline': build_land_headline(traditional_lands, dfc_extra_total, traditional_lands + dfc_extra_total), } # Mana curve (non-land spells) curve_bins = ['0','1','2','3','4','5','6+'] curve_counts = {b: 0 for b in curve_bins} curve_cards: Dict[str, list] = {b: [] for b in curve_bins} total_spells = 0 for name, info in self.card_library.items(): ctype = str(info.get('Card Type', '')) if 'land' in ctype.lower(): continue cnt = int(info.get('Count', 1)) mv = info.get('Mana Value') if mv in (None, ''): row = row_lookup.get(name) if row is not None: mv = row.get('manaValue', row.get('cmc', None)) try: val = float(mv) if mv not in (None, '') else 0.0 except Exception: val = 0.0 bucket = '6+' if val >= 6 else str(int(val)) if bucket not in curve_counts: bucket = '6+' curve_counts[bucket] += cnt curve_cards[bucket].append({'name': name, 'count': cnt}) total_spells += cnt # Include/exclude impact summary (M3: Include/Exclude Summary Panel) include_exclude_summary = {} diagnostics = getattr(self, 'include_exclude_diagnostics', None) if diagnostics: include_exclude_summary = { 'include_cards': list(getattr(self, 'include_cards', [])), 'exclude_cards': list(getattr(self, 'exclude_cards', [])), 'include_added': diagnostics.get('include_added', []), 'missing_includes': diagnostics.get('missing_includes', []), 'excluded_removed': diagnostics.get('excluded_removed', []), 'fuzzy_corrections': diagnostics.get('fuzzy_corrections', {}), 'illegal_dropped': diagnostics.get('illegal_dropped', []), 'illegal_allowed': diagnostics.get('illegal_allowed', []), 'ignored_color_identity': diagnostics.get('ignored_color_identity', []), 'duplicates_collapsed': diagnostics.get('duplicates_collapsed', {}), } summary_payload = { 'type_breakdown': { 'counts': type_counts, 'order': type_order, 'cards': type_cards, 'total': total_cards, }, 'pip_distribution': { 'counts': pip_counts, 'weights': pip_weights, 'cards': pip_cards, }, 'mana_generation': { **source_counts, 'total_sources': total_sources, 'cards': source_cards, }, 'mana_curve': { **curve_counts, 'total_spells': total_spells, 'cards': curve_cards, }, 'land_summary': land_summary, 'colors': list(getattr(self, 'color_identity', []) or []), 'include_exclude_summary': include_exclude_summary, } try: commander_meta = self.get_commander_export_metadata() except Exception: commander_meta = {} commander_names = commander_meta.get('commander_names') or [] if commander_names: summary_payload['commander'] = { 'names': commander_names, 'primary': commander_meta.get('primary_commander'), 'secondary': commander_meta.get('secondary_commander'), 'partner_mode': commander_meta.get('partner_mode'), 'color_identity': commander_meta.get('color_identity') or list(getattr(self, 'color_identity', []) or []), } combined_payload = commander_meta.get('combined_commander') if combined_payload: summary_payload['commander']['combined'] = combined_payload try: record_partner_summary(summary_payload['commander']) except Exception: # pragma: no cover - diagnostics only logger.debug("Failed to record partner telemetry", exc_info=True) try: record_land_summary(land_summary) except Exception: # pragma: no cover - diagnostics only logger.debug("Failed to record MDFC telemetry", exc_info=True) try: theme_payload = self.get_theme_summary_payload() if hasattr(self, "get_theme_summary_payload") else None if theme_payload: record_theme_summary(theme_payload) except Exception: # pragma: no cover - diagnostics only logger.debug("Failed to record theme telemetry", exc_info=True) return summary_payload 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 Included columns (enriched when possible): Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, Tags, Text Falls back gracefully if snapshot rows missing. """ 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: # Build a filename stem from either custom export base or commander/themes try: custom_base = getattr(self, 'custom_export_base', None) except Exception: custom_base = None date_part = _dt.date.today().strftime('%Y%m%d') if isinstance(custom_base, str) and custom_base.strip(): stem = f"{_slug(custom_base.strip())}_{date_part}" else: cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '' cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck' # Collect themes in order 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) stem = f"{cmdr_slug}_{theme_slug}_{date_part}" filename = f"{stem}.csv" fname = _unique_path(os.path.join(directory, filename)) full_df = getattr(self, '_full_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None) snapshot = full_df if full_df is not None else combined_df row_lookup: Dict[str, Any] = {} if snapshot is not None and not snapshot.empty and 'name' in snapshot.columns: for _, r in snapshot.iterrows(): nm = str(r.get('name')) if nm not in row_lookup: row_lookup[nm] = r builder_utils_module = None try: from deck_builder import builder_utils as builder_utils_module # type: ignore color_matrix = builder_utils_module.compute_color_source_matrix(self.card_library, full_df) except Exception: color_matrix = {} dfc_land_lookup: Dict[str, Dict[str, Any]] = {} for card_name, flags in color_matrix.items(): if not bool(flags.get('_dfc_land')): continue counts_as_extra = bool(flags.get('_dfc_counts_as_extra')) note_text = dfc_card_note(counts_as_extra) dfc_land_lookup[card_name] = { 'note': note_text, 'adds_extra_land': counts_as_extra, } headers = [ "Name","Count","Type","ManaCost","ManaValue","Colors","Power","Toughness", "Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","MetadataTags","Text","DFCNote","Owned" ] header_suffix: List[str] = [] try: commander_meta = self.get_commander_export_metadata() except Exception: commander_meta = {} commander_names = commander_meta.get('commander_names') or [] if commander_names: header_suffix.append(f"Commanders: {', '.join(commander_names)}") header_row = headers + header_suffix suffix_padding = [''] * len(header_suffix) # Precedence list for sorting precedence_order = [ 'Commander', 'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land' ] precedence_index = {k: i for i, k in enumerate(precedence_order)} commander_name = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '' def classify(primary_type_line: str, card_name: str) -> str: if commander_name and card_name == commander_name: return 'Commander' tl = (primary_type_line or '').lower() if 'battle' in tl: return 'Battle' if 'planeswalker' in tl: return 'Planeswalker' if 'creature' in tl: return 'Creature' if 'instant' in tl: return 'Instant' if 'sorcery' in tl: return 'Sorcery' if 'artifact' in tl: return 'Artifact' if 'enchantment' in tl: return 'Enchantment' if 'land' in tl: return 'Land' return 'ZZZ' rows: List[tuple] = [] # (sort_key, row_data) # Prepare owned lookup if available owned_set_lower = set() try: owned_set_lower = {n.lower() for n in (getattr(self, 'owned_card_names', set()) or set())} except Exception: owned_set_lower = set() # Fallback oracle text for basic lands to ensure CSV has meaningful text BASIC_TEXT = { 'Plains': '({T}: Add {W}.)', 'Island': '({T}: Add {U}.)', 'Swamp': '({T}: Add {B}.)', 'Mountain': '({T}: Add {R}.)', 'Forest': '({T}: Add {G}.)', 'Wastes': '({T}: Add {C}.)', } for name, info in self.card_library.items(): base_type = info.get('Card Type') or info.get('Type', '') base_mc = info.get('Mana Cost', '') base_mv = info.get('Mana Value', info.get('CMC', '')) role = info.get('Role', '') or '' tags = info.get('Tags', []) or [] tags_join = '; '.join(tags) # M5: Include metadata tags in export metadata_tags = info.get('MetadataTags', []) or [] metadata_tags_join = '; '.join(metadata_tags) text_field = '' colors = '' power = '' toughness = '' row = row_lookup.get(name) if row is not None: row_type = row.get('type', row.get('type_line', '')) if row_type: base_type = row_type mc = row.get('manaCost', '') if mc: base_mc = mc mv = row.get('manaValue', row.get('cmc', '')) if mv not in (None, ''): base_mv = mv colors_raw = row.get('colorIdentity', row.get('colors', [])) if isinstance(colors_raw, list): colors = ''.join(colors_raw) elif colors_raw not in (None, ''): colors = str(colors_raw) power = row.get('power', '') or '' toughness = row.get('toughness', '') or '' text_field = row.get('text', row.get('oracleText', '')) or '' # If still no text and this is a basic, inject fallback oracle snippet if (not text_field) and (str(name) in BASIC_TEXT): text_field = BASIC_TEXT[str(name)] # Normalize and coerce text if isinstance(text_field, str): cleaned = text_field else: try: import math as _math if isinstance(text_field, float) and (_math.isnan(text_field)): cleaned = '' else: cleaned = str(text_field) if text_field is not None else '' except Exception: cleaned = str(text_field) if text_field is not None else '' cleaned = cleaned.replace('\n', ' ').replace('\r', ' ') while ' ' in cleaned: cleaned = cleaned.replace(' ', ' ') text_field = cleaned cat = classify(base_type, name) prec = precedence_index.get(cat, 999) # Alphabetical within category (no mana value sorting) owned_flag = 'Y' if (name.lower() in owned_set_lower) else '' dfc_meta = dfc_land_lookup.get(name) dfc_note = '' if dfc_meta: note_text = dfc_meta.get('note') if note_text: dfc_note = f"MDFC: {note_text}" rows.append(((prec, name.lower()), [ name, info.get('Count', 1), base_type, base_mc, base_mv, colors, power, toughness, info.get('Role') or role, info.get('SubRole') or '', info.get('AddedBy') or '', info.get('TriggerTag') or '', info.get('Synergy') if info.get('Synergy') is not None else '', tags_join, metadata_tags_join, # M5: Include metadata tags text_field[:800] if isinstance(text_field, str) else str(text_field)[:800], dfc_note, owned_flag ])) # Now sort (category precedence, then alphabetical name) rows.sort(key=lambda x: x[0]) with open(fname, 'w', newline='', encoding='utf-8') as f: w = csv.writer(f) w.writerow(header_row) for _, data_row in rows: if suffix_padding: w.writerow(data_row + suffix_padding) else: w.writerow(data_row) self.output_func(f"Deck exported to {fname}") # Auto-generate matching plaintext list (best-effort; ignore failures) return fname 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 category precedence then alphabetical within category for consistency. """ os.makedirs(directory, exist_ok=True) # Derive base filename logic (shared with CSV exporter) – intentionally duplicated to avoid refactor risk. 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: # Prefer custom export base if provided; else fall back to commander/themes try: custom_base = getattr(self, 'custom_export_base', None) except Exception: custom_base = None date_part = _dt.date.today().strftime('%Y%m%d') if isinstance(custom_base, str) and custom_base.strip(): stem = f"{_slug(custom_base.strip())}_{date_part}" else: 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) stem = f"{cmdr_slug}_{theme_slug}_{date_part}" filename = f"{stem}.txt" if not filename.lower().endswith('.txt'): filename = filename + '.txt' path = _unique_path(os.path.join(directory, filename)) # Sorting reproduction precedence_order = [ 'Commander', 'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land' ] precedence_index = {k: i for i, k in enumerate(precedence_order)} commander_name = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '' def classify(primary_type_line: str, card_name: str) -> str: if commander_name and card_name == commander_name: return 'Commander' tl = (primary_type_line or '').lower() if 'battle' in tl: return 'Battle' if 'planeswalker' in tl: return 'Planeswalker' if 'creature' in tl: return 'Creature' if 'instant' in tl: return 'Instant' if 'sorcery' in tl: return 'Sorcery' if 'artifact' in tl: return 'Artifact' if 'enchantment' in tl: return 'Enchantment' if 'land' in tl: return 'Land' return 'ZZZ' # We may want enriched type lines from snapshot; build quick lookup full_df = getattr(self, '_full_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None) snapshot = full_df if full_df is not None else combined_df row_lookup: Dict[str, Any] = {} if snapshot is not None and not snapshot.empty and 'name' in snapshot.columns: for _, r in snapshot.iterrows(): nm = str(r.get('name')) if nm not in row_lookup: row_lookup[nm] = r try: from deck_builder import builder_utils as _builder_utils color_matrix = _builder_utils.compute_color_source_matrix(self.card_library, full_df) except Exception: color_matrix = {} dfc_land_lookup: Dict[str, str] = {} for card_name, flags in color_matrix.items(): if not bool(flags.get('_dfc_land')): continue counts_as_extra = bool(flags.get('_dfc_counts_as_extra')) dfc_land_lookup[card_name] = dfc_card_note(counts_as_extra) sortable: List[tuple] = [] for name, info in self.card_library.items(): base_type = info.get('Card Type') or info.get('Type','') row = row_lookup.get(name) if row is not None: row_type = row.get('type', row.get('type_line', '')) if row_type: base_type = row_type cat = classify(base_type, name) prec = precedence_index.get(cat, 999) dfc_note = dfc_land_lookup.get(name) sortable.append(((prec, name.lower()), name, info.get('Count',1), dfc_note)) sortable.sort(key=lambda x: x[0]) try: commander_meta = self.get_commander_export_metadata() except Exception: commander_meta = {} header_lines: List[str] = [] commander_names = commander_meta.get('commander_names') or [] if commander_names: header_lines.append(f"# Commanders: {', '.join(commander_names)}") partner_mode = commander_meta.get('partner_mode') if partner_mode and partner_mode not in (None, '', 'none'): header_lines.append(f"# Partner Mode: {partner_mode}") color_identity = commander_meta.get('color_identity') or [] if color_identity: header_lines.append(f"# Colors: {', '.join(color_identity)}") with open(path, 'w', encoding='utf-8') as f: if header_lines: f.write("\n".join(header_lines) + "\n\n") for _, name, count, dfc_note in sortable: line = f"{count} {name}" if dfc_note: line += f" [MDFC: {dfc_note}]" f.write(line + "\n") if not suppress_output: 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) - secondary_commander (when partner mechanics apply) - background (when Choose a Background is used) - enable_partner_mechanics flag (bool, default False) """ 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 def _clean_text(value: object | None) -> str | None: if value is None: return None if isinstance(value, str): text = value.strip() if not text: return None if text.lower() == "none": return None return text try: text = str(value).strip() except Exception: return None if not text: return None if text.lower() == "none": return None return text if filename is None: # Prefer a custom export base when present; else commander/themes try: custom_base = getattr(self, 'custom_export_base', None) except Exception: custom_base = None date_part = _dt.date.today().strftime('%Y%m%d') if isinstance(custom_base, str) and custom_base.strip(): stem = f"{_slug(custom_base.strip())}_{date_part}" else: 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) stem = f"{cmdr_slug}_{theme_slug}_{date_part}" filename = f"{stem}.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) user_themes: List[str] = [ str(theme) for theme in getattr(self, 'user_theme_requested', []) if isinstance(theme, str) and theme.strip() ] theme_catalog_version = getattr(self, 'theme_catalog_version', None) partner_enabled_flag = bool(getattr(self, 'partner_feature_enabled', False)) requested_secondary = _clean_text(getattr(self, 'requested_secondary_commander', None)) requested_background = _clean_text(getattr(self, 'requested_background', None)) stored_secondary = _clean_text(getattr(self, 'secondary_commander', None)) stored_background = _clean_text(getattr(self, 'background', None)) metadata: Dict[str, Any] = {} try: metadata_candidate = self.get_commander_export_metadata() except Exception: metadata_candidate = {} if isinstance(metadata_candidate, dict): metadata = metadata_candidate partner_mode = str(metadata.get("partner_mode") or "").strip().lower() if metadata else "" metadata_secondary = _clean_text(metadata.get("secondary_commander")) if metadata else None combined_secondary = None combined_info = metadata.get("combined_commander") if metadata else None if isinstance(combined_info, dict): combined_secondary = _clean_text(combined_info.get("secondary_name")) if partner_mode and partner_mode not in {"none", ""}: partner_enabled_flag = True if not partner_enabled_flag else partner_enabled_flag secondary_for_export = None background_for_export = None if partner_mode == "background": background_for_export = ( combined_secondary or requested_background or metadata_secondary or stored_background or stored_secondary ) else: secondary_for_export = ( combined_secondary or requested_secondary or metadata_secondary or stored_secondary ) background_for_export = requested_background or stored_background secondary_for_export = _clean_text(secondary_for_export) background_for_export = _clean_text(background_for_export) if partner_mode == "background": secondary_for_export = None enable_partner_flag = bool(partner_enabled_flag) 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), "tag_mode": (getattr(self, 'tag_mode', 'AND') or 'AND'), "use_multi_theme": True, "add_lands": True, "add_creatures": True, "add_non_creature_spells": True, # Combos preferences (if set during build) "prefer_combos": bool(getattr(self, 'prefer_combos', False)), "combo_target_count": (int(getattr(self, 'combo_target_count', 0)) if getattr(self, 'prefer_combos', False) else None), "combo_balance": (getattr(self, 'combo_balance', None) if getattr(self, 'prefer_combos', False) else None), # Include/Exclude configuration (M1: Config + Validation + Persistence) "include_cards": list(getattr(self, 'include_cards', [])), "exclude_cards": list(getattr(self, 'exclude_cards', [])), "enforcement_mode": getattr(self, 'enforcement_mode', 'warn'), "allow_illegal": bool(getattr(self, 'allow_illegal', False)), "fuzzy_matching": bool(getattr(self, 'fuzzy_matching', True)), "additional_themes": user_themes, "theme_match_mode": getattr(self, 'theme_match_mode', 'permissive'), "theme_catalog_version": theme_catalog_version, # CamelCase aliases for downstream consumers (web diagnostics, external tooling) "userThemes": user_themes, "themeCatalogVersion": theme_catalog_version, "secondary_commander": secondary_for_export, "background": background_for_export, "enable_partner_mechanics": enable_partner_flag, # 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. """ # Card library printout suppressed; use CSV and text export for card list. pass