mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
Finalize MDFC follow-ups, docs, and diagnostics tooling
document deck summary DFC badges, exporter annotations, and per-face metadata across README/DOCKER/release notes record completion of all MDFC roadmap follow-ups and add the authoring guide for multi-face CSV entries wire in optional DFC_PER_FACE_SNAPSHOT env support, exporter regression tests, and diagnostics updates noted in the changelog
This commit is contained in:
parent
6fefda714e
commit
88cf832bf2
46 changed files with 3292 additions and 86 deletions
|
|
@ -458,6 +458,8 @@ class DeckBuilder(
|
|||
fetch_count: Optional[int] = None
|
||||
# Whether this build is running in headless mode (suppress some interactive-only exports)
|
||||
headless: bool = False
|
||||
# Preference: swap a matching basic for modal double-faced lands when they are added
|
||||
swap_mdfc_basics: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
"""Post-init hook to wrap the provided output function so that all user-facing
|
||||
|
|
@ -1766,6 +1768,9 @@ class DeckBuilder(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# If configured, offset modal DFC land additions by trimming a matching basic
|
||||
self._maybe_offset_basic_for_modal_land(card_name)
|
||||
|
||||
def _remove_from_pool(self, card_name: str):
|
||||
if self._combined_cards_df is None:
|
||||
return
|
||||
|
|
@ -2275,6 +2280,59 @@ class DeckBuilder(
|
|||
|
||||
return bu.choose_basic_to_trim(self.card_library)
|
||||
|
||||
def _maybe_offset_basic_for_modal_land(self, card_name: str) -> None:
|
||||
"""If enabled, remove one matching basic when a modal DFC land is added."""
|
||||
if not getattr(self, 'swap_mdfc_basics', False):
|
||||
return
|
||||
try:
|
||||
entry = self.card_library.get(card_name)
|
||||
if entry and entry.get('Commander'):
|
||||
return
|
||||
# Force a fresh matrix so the newly added card is represented
|
||||
self._color_source_cache_dirty = True
|
||||
matrix = self._compute_color_source_matrix()
|
||||
except Exception:
|
||||
return
|
||||
colors = matrix.get(card_name)
|
||||
if not colors or not colors.get('_dfc_counts_as_extra'):
|
||||
return
|
||||
candidate_colors = [c for c in ['W', 'U', 'B', 'R', 'G', 'C'] if colors.get(c)]
|
||||
if not candidate_colors:
|
||||
return
|
||||
matches: List[tuple[int, str, str]] = []
|
||||
color_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {})
|
||||
snow_map = getattr(bc, 'SNOW_BASIC_LAND_MAPPING', {})
|
||||
for color in candidate_colors:
|
||||
names: List[str] = []
|
||||
base = color_map.get(color)
|
||||
if base:
|
||||
names.append(base)
|
||||
snow = snow_map.get(color)
|
||||
if snow and snow not in names:
|
||||
names.append(snow)
|
||||
for nm in names:
|
||||
entry = self.card_library.get(nm)
|
||||
if entry and entry.get('Count', 0) > 0:
|
||||
matches.append((int(entry.get('Count', 0)), nm, color))
|
||||
break
|
||||
if matches:
|
||||
matches.sort(key=lambda x: x[0], reverse=True)
|
||||
_, target_name, target_color = matches[0]
|
||||
if self._decrement_card(target_name):
|
||||
logger.info(
|
||||
"MDFC swap: %s removed %s to keep land totals aligned",
|
||||
card_name,
|
||||
target_name,
|
||||
)
|
||||
return
|
||||
fallback = self._choose_basic_to_trim()
|
||||
if fallback and self._decrement_card(fallback):
|
||||
logger.info(
|
||||
"MDFC swap fallback: %s trimmed %s to maintain land total",
|
||||
card_name,
|
||||
fallback,
|
||||
)
|
||||
|
||||
def _decrement_card(self, name: str) -> bool:
|
||||
entry = self.card_library.get(name)
|
||||
if not entry:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ MAX_FUZZY_CHOICES: Final[int] = 5 # Maximum number of fuzzy match choices
|
|||
DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}'
|
||||
COMMANDER_CSV_PATH: Final[str] = f"{csv_dir()}/commander_cards.csv"
|
||||
DECK_DIRECTORY = '../deck_files'
|
||||
COMMANDER_CONVERTERS: Final[Dict[str, str]] = {'themeTags': ast.literal_eval, 'creatureTypes': ast.literal_eval} # CSV loading converters
|
||||
COMMANDER_CONVERTERS: Final[Dict[str, str]] = {
|
||||
'themeTags': ast.literal_eval,
|
||||
'creatureTypes': ast.literal_eval,
|
||||
'roleTags': ast.literal_eval,
|
||||
} # CSV loading converters
|
||||
COMMANDER_POWER_DEFAULT: Final[int] = 0
|
||||
COMMANDER_TOUGHNESS_DEFAULT: Final[int] = 0
|
||||
COMMANDER_MANA_VALUE_DEFAULT: Final[int] = 0
|
||||
|
|
|
|||
|
|
@ -8,15 +8,159 @@ Only import lightweight standard library modules here to avoid import cycles.
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Iterable
|
||||
from typing import Any, Dict, Iterable, List
|
||||
import re
|
||||
import ast
|
||||
import random as _rand
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from . import builder_constants as bc
|
||||
import math
|
||||
from path_util import csv_dir
|
||||
|
||||
COLOR_LETTERS = ['W', 'U', 'B', 'R', 'G']
|
||||
_MULTI_FACE_LAYOUTS = {
|
||||
"adventure",
|
||||
"aftermath",
|
||||
"augment",
|
||||
"flip",
|
||||
"host",
|
||||
"meld",
|
||||
"modal_dfc",
|
||||
"reversible_card",
|
||||
"split",
|
||||
"transform",
|
||||
}
|
||||
_SIDE_PRIORITY = {
|
||||
"": 0,
|
||||
"a": 0,
|
||||
"front": 0,
|
||||
"main": 0,
|
||||
"b": 1,
|
||||
"back": 1,
|
||||
"c": 2,
|
||||
}
|
||||
|
||||
|
||||
def _detect_produces_mana(text: str) -> bool:
|
||||
text = (text or "").lower()
|
||||
if not text:
|
||||
return False
|
||||
if 'add one mana of any color' in text or 'add one mana of any colour' in text:
|
||||
return True
|
||||
if 'add mana of any color' in text or 'add mana of any colour' in text:
|
||||
return True
|
||||
if 'mana of any one color' in text or 'any color of mana' in text:
|
||||
return True
|
||||
if 'add' in text:
|
||||
for sym in ('{w}', '{u}', '{b}', '{r}', '{g}', '{c}'):
|
||||
if sym in text:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _resolved_csv_dir(base_dir: str | None = None) -> str:
|
||||
try:
|
||||
if base_dir:
|
||||
return str(Path(base_dir).resolve())
|
||||
return str(Path(csv_dir()).resolve())
|
||||
except Exception:
|
||||
return base_dir or csv_dir()
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
|
||||
"""Load mapping of multi-faced cards that have at least one land face."""
|
||||
try:
|
||||
base_path = Path(base_dir)
|
||||
csv_path = base_path / 'cards.csv'
|
||||
if not csv_path.exists():
|
||||
return {}
|
||||
usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName']
|
||||
df = pd.read_csv(csv_path, usecols=usecols, low_memory=False)
|
||||
except Exception:
|
||||
return {}
|
||||
if df.empty or 'layout' not in df.columns or 'type' not in df.columns:
|
||||
return {}
|
||||
df['layout'] = df['layout'].fillna('').astype(str).str.lower()
|
||||
multi_df = df[df['layout'].isin(_MULTI_FACE_LAYOUTS)].copy()
|
||||
if multi_df.empty:
|
||||
return {}
|
||||
multi_df['type'] = multi_df['type'].fillna('').astype(str)
|
||||
multi_df['side'] = multi_df['side'].fillna('').astype(str)
|
||||
multi_df['text'] = multi_df['text'].fillna('').astype(str)
|
||||
land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)]
|
||||
if land_rows.empty:
|
||||
return {}
|
||||
mapping: Dict[str, Dict[str, Any]] = {}
|
||||
for name, group in land_rows.groupby('name', sort=False):
|
||||
faces: List[Dict[str, str]] = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
front_is_land = False
|
||||
layout_val = ''
|
||||
for _, row in group.iterrows():
|
||||
side_raw = str(row.get('side', '') or '').strip()
|
||||
side_key = side_raw.lower()
|
||||
if not side_key:
|
||||
side_key = 'a'
|
||||
type_val = str(row.get('type', '') or '')
|
||||
text_val = str(row.get('text', '') or '')
|
||||
mana_cost_val = str(row.get('manaCost', '') or '')
|
||||
mana_value_raw = row.get('manaValue', '')
|
||||
mana_value_val = None
|
||||
try:
|
||||
if mana_value_raw not in (None, ''):
|
||||
mana_value_val = float(mana_value_raw)
|
||||
if math.isnan(mana_value_val):
|
||||
mana_value_val = None
|
||||
except Exception:
|
||||
mana_value_val = None
|
||||
face_label = str(row.get('faceName', '') or row.get('name', '') or '')
|
||||
produces_mana = _detect_produces_mana(text_val)
|
||||
signature = (side_key, type_val, text_val)
|
||||
if signature in seen:
|
||||
continue
|
||||
seen.add(signature)
|
||||
faces.append({
|
||||
'face': face_label,
|
||||
'side': side_key,
|
||||
'type': type_val,
|
||||
'text': text_val,
|
||||
'mana_cost': mana_cost_val,
|
||||
'mana_value': mana_value_val,
|
||||
'produces_mana': produces_mana,
|
||||
'is_land': 'land' in type_val.lower(),
|
||||
'layout': str(row.get('layout', '') or ''),
|
||||
})
|
||||
if side_key in ('', 'a', 'front', 'main'):
|
||||
front_is_land = True
|
||||
layout_val = layout_val or str(row.get('layout', '') or '')
|
||||
if not faces:
|
||||
continue
|
||||
faces.sort(key=lambda face: _SIDE_PRIORITY.get(face.get('side', ''), 3))
|
||||
mapping[name] = {
|
||||
'faces': faces,
|
||||
'front_is_land': front_is_land,
|
||||
'layout': layout_val,
|
||||
}
|
||||
return mapping
|
||||
|
||||
|
||||
def multi_face_land_info(name: str, base_dir: str | None = None) -> Dict[str, Any]:
|
||||
return _load_multi_face_land_map(_resolved_csv_dir(base_dir)).get(name, {})
|
||||
|
||||
|
||||
def get_multi_face_land_faces(name: str, base_dir: str | None = None) -> List[Dict[str, str]]:
|
||||
entry = multi_face_land_info(name, base_dir)
|
||||
return list(entry.get('faces', []))
|
||||
|
||||
|
||||
def has_multi_face_land(name: str, base_dir: str | None = None) -> bool:
|
||||
entry = multi_face_land_info(name, base_dir)
|
||||
return bool(entry and entry.get('faces'))
|
||||
|
||||
|
||||
def parse_theme_tags(val) -> list[str]:
|
||||
|
|
@ -90,13 +234,49 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
|
|||
nm = str(r.get('name', ''))
|
||||
if nm and nm not in lookup:
|
||||
lookup[nm] = r
|
||||
try:
|
||||
dfc_map = _load_multi_face_land_map(_resolved_csv_dir())
|
||||
except Exception:
|
||||
dfc_map = {}
|
||||
for name, entry in card_library.items():
|
||||
row = lookup.get(name, {})
|
||||
entry_type = str(entry.get('Card Type') or entry.get('Type') or '').lower()
|
||||
tline_full = str(row.get('type', row.get('type_line', '')) or '').lower()
|
||||
entry_type_raw = str(entry.get('Card Type') or entry.get('Type') or '')
|
||||
entry_type = entry_type_raw.lower()
|
||||
row_type_raw = ''
|
||||
if hasattr(row, 'get'):
|
||||
row_type_raw = row.get('type', row.get('type_line', '')) or ''
|
||||
tline_full = str(row_type_raw).lower()
|
||||
# Land or permanent that could produce mana via text
|
||||
is_land = ('land' in entry_type) or ('land' in tline_full)
|
||||
text_field = str(row.get('text', row.get('oracleText', '')) or '').lower()
|
||||
base_is_land = is_land
|
||||
text_field_raw = ''
|
||||
if hasattr(row, 'get'):
|
||||
text_field_raw = row.get('text', row.get('oracleText', '')) or ''
|
||||
if pd.isna(text_field_raw):
|
||||
text_field_raw = ''
|
||||
text_field_raw = str(text_field_raw)
|
||||
dfc_entry = dfc_map.get(name)
|
||||
if dfc_entry:
|
||||
faces = dfc_entry.get('faces', []) or []
|
||||
if faces:
|
||||
face_types: List[str] = []
|
||||
face_texts: List[str] = []
|
||||
for face in faces:
|
||||
type_val = str(face.get('type', '') or '')
|
||||
text_val = str(face.get('text', '') or '')
|
||||
if type_val:
|
||||
face_types.append(type_val)
|
||||
if text_val:
|
||||
face_texts.append(text_val)
|
||||
if face_types:
|
||||
joined_types = ' '.join(face_types)
|
||||
tline_full = (tline_full + ' ' + joined_types.lower()).strip()
|
||||
if face_texts:
|
||||
joined_text = ' '.join(face_texts)
|
||||
text_field_raw = (text_field_raw + ' ' + joined_text).strip()
|
||||
if face_types or face_texts:
|
||||
is_land = True
|
||||
text_field = text_field_raw.lower().replace('\n', ' ')
|
||||
# Skip obvious non-permanents (rituals etc.)
|
||||
if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full):
|
||||
continue
|
||||
|
|
@ -166,8 +346,13 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
|
|||
col = mapping.get(base)
|
||||
if col:
|
||||
colors[col] = 1
|
||||
# Only include cards that produced at least one color
|
||||
if any(colors.values()):
|
||||
dfc_is_land = bool(dfc_entry and dfc_entry.get('faces'))
|
||||
if dfc_is_land:
|
||||
colors['_dfc_land'] = True
|
||||
if not (base_is_land or dfc_entry.get('front_is_land')):
|
||||
colors['_dfc_counts_as_extra'] = True
|
||||
produces_any_color = any(colors[c] for c in ('W', 'U', 'B', 'R', 'G', 'C'))
|
||||
if produces_any_color or colors.get('_dfc_land'):
|
||||
matrix[name] = colors
|
||||
return matrix
|
||||
|
||||
|
|
@ -210,11 +395,15 @@ def compute_spell_pip_weights(card_library: Dict[str, dict], color_identity: Ite
|
|||
return {c: (pip_counts[c] / total_colored) for c in pip_counts}
|
||||
|
||||
|
||||
|
||||
__all__ = [
|
||||
'compute_color_source_matrix',
|
||||
'compute_spell_pip_weights',
|
||||
'parse_theme_tags',
|
||||
'normalize_theme_list',
|
||||
'multi_face_land_info',
|
||||
'get_multi_face_land_faces',
|
||||
'has_multi_face_land',
|
||||
'detect_viable_multi_copy_archetypes',
|
||||
'prefer_owned_first',
|
||||
'compute_adjusted_target',
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
from typing import Any, Dict, List
|
||||
import csv
|
||||
import os
|
||||
import datetime as _dt
|
||||
import re as _re
|
||||
import logging_util
|
||||
|
||||
from code.deck_builder.summary_telemetry import record_land_summary
|
||||
from code.deck_builder.shared_copy import build_land_headline, dfc_card_note
|
||||
|
||||
logger = logging_util.logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
|
|
@ -285,6 +288,36 @@ class ReportingMixin:
|
|||
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() # type: ignore[attr-defined]
|
||||
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)
|
||||
# ---------------------------
|
||||
|
|
@ -347,6 +380,41 @@ class ReportingMixin:
|
|||
return 'Land'
|
||||
return 'Other'
|
||||
|
||||
builder_utils_module = None
|
||||
try:
|
||||
from deck_builder import builder_utils as _builder_utils # type: ignore
|
||||
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
|
||||
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] = {}
|
||||
|
|
@ -364,17 +432,31 @@ class ReportingMixin:
|
|||
category = classify(base_type, name)
|
||||
type_counts[category] = type_counts.get(category, 0) + cnt
|
||||
total_cards += cnt
|
||||
type_cards.setdefault(category, []).append({
|
||||
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
|
||||
|
|
@ -425,21 +507,52 @@ class ReportingMixin:
|
|||
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)
|
||||
try:
|
||||
from deck_builder import builder_utils as _bu
|
||||
matrix = _bu.compute_color_source_matrix(self.card_library, full_df)
|
||||
except Exception:
|
||||
matrix = {}
|
||||
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
|
||||
source_cards[c].append({'name': name, 'count': 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,
|
||||
})
|
||||
if adds_extra:
|
||||
dfc_extra_total += copies
|
||||
total_sources = sum(source_counts.values())
|
||||
traditional_lands = type_counts.get('Land', 0)
|
||||
land_summary = {
|
||||
'traditional': traditional_lands,
|
||||
'dfc_lands': dfc_extra_total,
|
||||
'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+']
|
||||
|
|
@ -484,7 +597,7 @@ class ReportingMixin:
|
|||
'duplicates_collapsed': diagnostics.get('duplicates_collapsed', {}),
|
||||
}
|
||||
|
||||
return {
|
||||
summary_payload = {
|
||||
'type_breakdown': {
|
||||
'counts': type_counts,
|
||||
'order': type_order,
|
||||
|
|
@ -506,9 +619,15 @@ class ReportingMixin:
|
|||
'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:
|
||||
record_land_summary(land_summary)
|
||||
except Exception: # pragma: no cover - diagnostics only
|
||||
logger.debug("Failed to record MDFC 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
|
||||
|
|
@ -574,9 +693,26 @@ class ReportingMixin:
|
|||
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","Text","Owned"
|
||||
"Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","Text","DFCNote","Owned"
|
||||
]
|
||||
|
||||
# Precedence list for sorting
|
||||
|
|
@ -680,6 +816,12 @@ class ReportingMixin:
|
|||
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),
|
||||
|
|
@ -696,6 +838,7 @@ class ReportingMixin:
|
|||
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],
|
||||
dfc_note,
|
||||
owned_flag
|
||||
]))
|
||||
|
||||
|
|
@ -804,6 +947,18 @@ class ReportingMixin:
|
|||
if nm not in row_lookup:
|
||||
row_lookup[nm] = r
|
||||
|
||||
try:
|
||||
from deck_builder import builder_utils as _builder_utils # type: ignore
|
||||
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','')
|
||||
|
|
@ -814,12 +969,16 @@ class ReportingMixin:
|
|||
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)))
|
||||
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])
|
||||
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
for _, name, count in sortable:
|
||||
f.write(f"{count} {name}\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
|
||||
|
|
|
|||
30
code/deck_builder/shared_copy.py
Normal file
30
code/deck_builder/shared_copy.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Shared text helpers to keep CLI and web copy in sync."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
__all__ = ["build_land_headline", "dfc_card_note"]
|
||||
|
||||
|
||||
def build_land_headline(traditional: int, dfc_bonus: int, with_dfc: Optional[int] = None) -> str:
|
||||
"""Return the consistent land summary headline.
|
||||
|
||||
Args:
|
||||
traditional: Count of traditional land slots.
|
||||
dfc_bonus: Number of MDFC lands counted as additional slots.
|
||||
with_dfc: Optional total including MDFC lands. If omitted, the sum of
|
||||
``traditional`` and ``dfc_bonus`` is used.
|
||||
"""
|
||||
base = max(int(traditional), 0)
|
||||
bonus = max(int(dfc_bonus), 0)
|
||||
total = int(with_dfc) if with_dfc is not None else base + bonus
|
||||
headline = f"Lands: {base}"
|
||||
if bonus:
|
||||
headline += f" ({total} with DFC)"
|
||||
return headline
|
||||
|
||||
|
||||
def dfc_card_note(counts_as_extra: bool) -> str:
|
||||
"""Return the descriptive note for an MDFC land entry."""
|
||||
return "Adds extra land slot" if counts_as_extra else "Counts as land slot"
|
||||
122
code/deck_builder/summary_telemetry.py
Normal file
122
code/deck_builder/summary_telemetry.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter
|
||||
from typing import Any, Dict, Iterable
|
||||
|
||||
__all__ = [
|
||||
"record_land_summary",
|
||||
"get_mdfc_metrics",
|
||||
]
|
||||
|
||||
|
||||
_lock = threading.Lock()
|
||||
_metrics: Dict[str, Any] = {
|
||||
"total_builds": 0,
|
||||
"builds_with_mdfc": 0,
|
||||
"total_mdfc_lands": 0,
|
||||
"last_updated": None,
|
||||
"last_updated_iso": None,
|
||||
"last_summary": None,
|
||||
}
|
||||
_top_cards: Counter[str] = Counter()
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int:
|
||||
try:
|
||||
if value is None:
|
||||
return 0
|
||||
if isinstance(value, bool):
|
||||
return int(value)
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _sanitize_cards(cards: Iterable[Dict[str, Any]] | None) -> list[Dict[str, Any]]:
|
||||
if not cards:
|
||||
return []
|
||||
sanitized: list[Dict[str, Any]] = []
|
||||
for entry in cards:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
count = _to_int(entry.get("count", 1)) or 1
|
||||
colors = entry.get("colors")
|
||||
if isinstance(colors, (list, tuple)):
|
||||
color_list = [str(c) for c in colors if str(c)]
|
||||
else:
|
||||
color_list = []
|
||||
sanitized.append(
|
||||
{
|
||||
"name": name,
|
||||
"count": count,
|
||||
"colors": color_list,
|
||||
"counts_as_land": bool(entry.get("counts_as_land")),
|
||||
"adds_extra_land": bool(entry.get("adds_extra_land")),
|
||||
}
|
||||
)
|
||||
return sanitized
|
||||
|
||||
|
||||
def record_land_summary(land_summary: Dict[str, Any] | None) -> None:
|
||||
if not isinstance(land_summary, dict):
|
||||
return
|
||||
|
||||
dfc_lands = _to_int(land_summary.get("dfc_lands"))
|
||||
with_dfc = _to_int(land_summary.get("with_dfc"))
|
||||
timestamp = time.time()
|
||||
cards = _sanitize_cards(land_summary.get("dfc_cards"))
|
||||
|
||||
with _lock:
|
||||
_metrics["total_builds"] = int(_metrics.get("total_builds", 0)) + 1
|
||||
if dfc_lands > 0:
|
||||
_metrics["builds_with_mdfc"] = int(_metrics.get("builds_with_mdfc", 0)) + 1
|
||||
_metrics["total_mdfc_lands"] = int(_metrics.get("total_mdfc_lands", 0)) + dfc_lands
|
||||
for entry in cards:
|
||||
_top_cards[entry["name"]] += entry["count"]
|
||||
_metrics["last_summary"] = {
|
||||
"dfc_lands": dfc_lands,
|
||||
"with_dfc": with_dfc,
|
||||
"cards": cards,
|
||||
}
|
||||
_metrics["last_updated"] = timestamp
|
||||
_metrics["last_updated_iso"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(timestamp))
|
||||
|
||||
|
||||
def get_mdfc_metrics() -> Dict[str, Any]:
|
||||
with _lock:
|
||||
builds = int(_metrics.get("total_builds", 0) or 0)
|
||||
builds_with = int(_metrics.get("builds_with_mdfc", 0) or 0)
|
||||
total_lands = int(_metrics.get("total_mdfc_lands", 0) or 0)
|
||||
ratio = (builds_with / builds) if builds else 0.0
|
||||
avg_lands = (total_lands / builds_with) if builds_with else 0.0
|
||||
top_cards = dict(_top_cards.most_common(10))
|
||||
return {
|
||||
"total_builds": builds,
|
||||
"builds_with_mdfc": builds_with,
|
||||
"build_share": ratio,
|
||||
"total_mdfc_lands": total_lands,
|
||||
"avg_mdfc_lands": avg_lands,
|
||||
"top_cards": top_cards,
|
||||
"last_summary": _metrics.get("last_summary"),
|
||||
"last_updated": _metrics.get("last_updated_iso"),
|
||||
}
|
||||
|
||||
|
||||
def _reset_metrics_for_test() -> None:
|
||||
with _lock:
|
||||
_metrics.update(
|
||||
{
|
||||
"total_builds": 0,
|
||||
"builds_with_mdfc": 0,
|
||||
"total_mdfc_lands": 0,
|
||||
"last_updated": None,
|
||||
"last_updated_iso": None,
|
||||
"last_summary": None,
|
||||
}
|
||||
)
|
||||
_top_cards.clear()
|
||||
Loading…
Add table
Add a link
Reference in a new issue