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:
matt 2025-10-02 15:31:05 -07:00
parent 6fefda714e
commit 88cf832bf2
46 changed files with 3292 additions and 86 deletions

View file

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

View file

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

View file

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

View file

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

View 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"

View 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()