mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
483 lines
21 KiB
Python
483 lines
21 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Dict, List
|
||
import csv
|
||
import os
|
||
import datetime as _dt
|
||
import re as _re
|
||
import logging_util
|
||
|
||
logger = logging_util.logging.getLogger(__name__)
|
||
|
||
try:
|
||
from prettytable import PrettyTable # type: ignore
|
||
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 = []
|
||
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}%)")
|
||
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:
|
||
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)
|
||
date_part = _dt.date.today().strftime('%Y%m%d')
|
||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.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
|
||
|
||
headers = [
|
||
"Name","Count","Type","ManaCost","ManaValue","Colors","Power","Toughness",
|
||
"Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","Text","Owned"
|
||
]
|
||
|
||
# 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()
|
||
|
||
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)
|
||
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 ''
|
||
# 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 ''
|
||
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,
|
||
text_field[:800] if isinstance(text_field, str) else str(text_field)[:800],
|
||
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(headers)
|
||
for _, data_row in rows:
|
||
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:
|
||
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)
|
||
date_part = _dt.date.today().strftime('%Y%m%d')
|
||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.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
|
||
|
||
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)
|
||
sortable.append(((prec, name.lower()), name, info.get('Count',1)))
|
||
sortable.sort(key=lambda x: x[0])
|
||
|
||
with open(path, 'w', encoding='utf-8') as f:
|
||
for _, name, count in sortable:
|
||
f.write(f"{count} {name}\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)
|
||
"""
|
||
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:
|
||
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)
|
||
date_part = _dt.date.today().strftime('%Y%m%d')
|
||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.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)
|
||
|
||
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),
|
||
"use_multi_theme": True,
|
||
"add_lands": True,
|
||
"add_creatures": True,
|
||
"add_non_creature_spells": True,
|
||
# 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
|