mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
Merge branch 'main' into bugfix/fix-ci-testing
This commit is contained in:
commit
a512f9b290
47 changed files with 3356 additions and 113 deletions
97
code/commander_exclusions.py
Normal file
97
code/commander_exclusions.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from settings import CSV_DIRECTORY
|
||||
|
||||
|
||||
def _normalize(value: Any) -> str:
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
|
||||
def _exclusions_path() -> Path:
|
||||
return Path(CSV_DIRECTORY) / ".commander_exclusions.json"
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _load_index_cached(path_str: str, mtime: float) -> Dict[str, Dict[str, Any]]:
|
||||
path = Path(path_str)
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except Exception:
|
||||
return {}
|
||||
entries = data.get("secondary_face_only")
|
||||
if not isinstance(entries, list):
|
||||
return {}
|
||||
index: Dict[str, Dict[str, Any]] = {}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
aliases = []
|
||||
for key in (entry.get("name"), entry.get("primary_face")):
|
||||
if key:
|
||||
aliases.append(str(key))
|
||||
faces = entry.get("faces")
|
||||
if isinstance(faces, list):
|
||||
aliases.extend(str(face) for face in faces if face)
|
||||
eligible = entry.get("eligible_faces")
|
||||
if isinstance(eligible, list):
|
||||
aliases.extend(str(face) for face in eligible if face)
|
||||
for alias in aliases:
|
||||
norm = _normalize(alias)
|
||||
if not norm:
|
||||
continue
|
||||
index[norm] = entry
|
||||
return index
|
||||
|
||||
|
||||
def _load_index() -> Dict[str, Dict[str, Any]]:
|
||||
path = _exclusions_path()
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
stat = path.stat()
|
||||
mtime = float(f"{stat.st_mtime:.6f}")
|
||||
except Exception:
|
||||
mtime = 0.0
|
||||
return _load_index_cached(str(path.resolve()), mtime)
|
||||
|
||||
|
||||
def lookup_commander(name: str) -> Optional[Dict[str, Any]]:
|
||||
if not name:
|
||||
return None
|
||||
index = _load_index()
|
||||
return index.get(_normalize(name))
|
||||
|
||||
|
||||
def lookup_commander_detail(name: str) -> Optional[Dict[str, Any]]:
|
||||
entry = lookup_commander(name)
|
||||
if entry is None:
|
||||
return None
|
||||
data = dict(entry)
|
||||
data.setdefault("primary_face", entry.get("primary_face") or entry.get("name"))
|
||||
data.setdefault("eligible_faces", entry.get("eligible_faces") or [])
|
||||
data.setdefault("reason", "secondary_face_only")
|
||||
return data
|
||||
|
||||
|
||||
def exclusions_summary() -> Dict[str, Any]:
|
||||
index = _load_index()
|
||||
return {
|
||||
"count": len(index),
|
||||
"entries": sorted(
|
||||
[
|
||||
{
|
||||
"name": entry.get("name") or entry.get("primary_face") or key,
|
||||
"primary_face": entry.get("primary_face") or entry.get("name") or key,
|
||||
"eligible_faces": entry.get("eligible_faces") or [],
|
||||
}
|
||||
for key, entry in index.items()
|
||||
],
|
||||
key=lambda x: x["name"],
|
||||
),
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -39,6 +39,7 @@ from .setup_utils import (
|
|||
process_legendary_cards,
|
||||
check_csv_exists,
|
||||
save_color_filtered_csvs,
|
||||
enrich_commander_rows_with_tags,
|
||||
)
|
||||
from exceptions import (
|
||||
CSVFileNotFoundError,
|
||||
|
|
@ -136,6 +137,9 @@ def determine_commanders() -> None:
|
|||
logger.info('Applying standard card filters')
|
||||
filtered_df = filter_dataframe(filtered_df, BANNED_CARDS)
|
||||
|
||||
logger.info('Enriching commander metadata with theme and creature tags')
|
||||
filtered_df = enrich_commander_rows_with_tags(filtered_df, CSV_DIRECTORY)
|
||||
|
||||
# Save commander cards
|
||||
logger.info('Saving validated commander cards')
|
||||
filtered_df.to_csv(f'{CSV_DIRECTORY}/commander_cards.csv', index=False)
|
||||
|
|
|
|||
|
|
@ -17,13 +17,16 @@ The module integrates with settings.py for configuration and exceptions.py for e
|
|||
from __future__ import annotations
|
||||
|
||||
# Standard library imports
|
||||
import ast
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union, TypedDict
|
||||
from typing import List, Optional, Union, TypedDict, Iterable, Dict, Any
|
||||
|
||||
# Third-party imports
|
||||
import pandas as pd
|
||||
from tqdm import tqdm
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Local application imports
|
||||
from .setup_constants import (
|
||||
|
|
@ -45,7 +48,7 @@ from exceptions import (
|
|||
CommanderValidationError
|
||||
)
|
||||
from type_definitions import CardLibraryDF
|
||||
from settings import FILL_NA_COLUMNS
|
||||
from settings import FILL_NA_COLUMNS, CSV_DIRECTORY
|
||||
import logging_util
|
||||
|
||||
# Create logger for this module
|
||||
|
|
@ -54,6 +57,251 @@ logger.setLevel(logging_util.LOG_LEVEL)
|
|||
logger.addHandler(logging_util.file_handler)
|
||||
logger.addHandler(logging_util.stream_handler)
|
||||
|
||||
|
||||
def _is_primary_side(value: object) -> bool:
|
||||
"""Return True when the provided side marker corresponds to a primary face."""
|
||||
try:
|
||||
if pd.isna(value):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
text = str(value).strip().lower()
|
||||
return text in {"", "a"}
|
||||
|
||||
|
||||
def _summarize_secondary_face_exclusions(
|
||||
names: Iterable[str],
|
||||
source_df: pd.DataFrame,
|
||||
) -> List[Dict[str, Any]]:
|
||||
summaries: List[Dict[str, Any]] = []
|
||||
if not names:
|
||||
return summaries
|
||||
|
||||
for raw_name in names:
|
||||
name = str(raw_name)
|
||||
group = source_df[source_df['name'] == name]
|
||||
if group.empty:
|
||||
continue
|
||||
|
||||
primary_rows = group[group['side'].apply(_is_primary_side)] if 'side' in group.columns else pd.DataFrame()
|
||||
primary_face = (
|
||||
str(primary_rows['faceName'].iloc[0])
|
||||
if not primary_rows.empty and 'faceName' in primary_rows.columns
|
||||
else ""
|
||||
)
|
||||
layout = str(group['layout'].iloc[0]) if 'layout' in group.columns and not group.empty else ""
|
||||
faces = sorted(set(str(v) for v in group.get('faceName', pd.Series(dtype=str)).dropna().tolist()))
|
||||
eligible_faces = sorted(
|
||||
set(
|
||||
str(v)
|
||||
for v in group
|
||||
.loc[~group['side'].apply(_is_primary_side) if 'side' in group.columns else [False] * len(group)]
|
||||
.get('faceName', pd.Series(dtype=str))
|
||||
.dropna()
|
||||
.tolist()
|
||||
)
|
||||
)
|
||||
|
||||
summaries.append(
|
||||
{
|
||||
"name": name,
|
||||
"primary_face": primary_face or name.split('//')[0].strip(),
|
||||
"layout": layout,
|
||||
"faces": faces,
|
||||
"eligible_faces": eligible_faces,
|
||||
"reason": "secondary_face_only",
|
||||
}
|
||||
)
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
def _write_commander_exclusions_log(entries: List[Dict[str, Any]]) -> None:
|
||||
"""Persist commander exclusion diagnostics for downstream tooling."""
|
||||
|
||||
path = Path(CSV_DIRECTORY) / ".commander_exclusions.json"
|
||||
|
||||
if not entries:
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.debug("Unable to remove commander exclusion log: %s", exc)
|
||||
return
|
||||
|
||||
payload = {
|
||||
"generated_at": datetime.now().isoformat(timespec='seconds'),
|
||||
"secondary_face_only": entries,
|
||||
}
|
||||
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open('w', encoding='utf-8') as handle:
|
||||
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to write commander exclusion diagnostics: %s", exc)
|
||||
|
||||
|
||||
def _enforce_primary_face_commander_rules(
|
||||
candidate_df: pd.DataFrame,
|
||||
source_df: pd.DataFrame,
|
||||
) -> pd.DataFrame:
|
||||
"""Retain only primary faces and record any secondary-face-only exclusions."""
|
||||
|
||||
if candidate_df.empty or 'side' not in candidate_df.columns:
|
||||
_write_commander_exclusions_log([])
|
||||
return candidate_df
|
||||
|
||||
mask_primary = candidate_df['side'].apply(_is_primary_side)
|
||||
primary_df = candidate_df[mask_primary].copy()
|
||||
secondary_df = candidate_df[~mask_primary]
|
||||
|
||||
primary_names = set(str(n) for n in primary_df.get('name', pd.Series(dtype=str)))
|
||||
secondary_only_names = sorted(
|
||||
set(str(n) for n in secondary_df.get('name', pd.Series(dtype=str))) - primary_names
|
||||
)
|
||||
|
||||
if secondary_only_names:
|
||||
logger.info(
|
||||
"Excluding %d commander entries where only a secondary face is eligible: %s",
|
||||
len(secondary_only_names),
|
||||
", ".join(secondary_only_names),
|
||||
)
|
||||
|
||||
entries = _summarize_secondary_face_exclusions(secondary_only_names, source_df)
|
||||
_write_commander_exclusions_log(entries)
|
||||
|
||||
return primary_df
|
||||
|
||||
|
||||
def _coerce_tag_list(value: object) -> List[str]:
|
||||
"""Normalize various list-like representations into a list of strings."""
|
||||
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, float) and pd.isna(value):
|
||||
return []
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [str(v).strip() for v in value if str(v).strip()]
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
parsed = ast.literal_eval(text)
|
||||
if isinstance(parsed, (list, tuple, set)):
|
||||
return [str(v).strip() for v in parsed if str(v).strip()]
|
||||
except Exception:
|
||||
pass
|
||||
parts = [part.strip() for part in text.replace(";", ",").split(",")]
|
||||
return [part for part in parts if part]
|
||||
|
||||
|
||||
def _collect_commander_tag_metadata(csv_dir: Union[str, Path]) -> Dict[str, Dict[str, List[str]]]:
|
||||
"""Aggregate theme and creature tags from color-tagged CSV files."""
|
||||
|
||||
path = Path(csv_dir)
|
||||
if not path.exists():
|
||||
return {}
|
||||
|
||||
combined: Dict[str, Dict[str, set[str]]] = {}
|
||||
columns = ("themeTags", "creatureTypes", "roleTags")
|
||||
|
||||
for color in SETUP_COLORS:
|
||||
color_path = path / f"{color}_cards.csv"
|
||||
if not color_path.exists():
|
||||
continue
|
||||
try:
|
||||
df = pd.read_csv(color_path, low_memory=False)
|
||||
except Exception as exc:
|
||||
logger.debug("Unable to read %s for commander tag enrichment: %s", color_path, exc)
|
||||
continue
|
||||
|
||||
if df.empty or ("name" not in df.columns and "faceName" not in df.columns):
|
||||
continue
|
||||
|
||||
for _, row in df.iterrows():
|
||||
face_key = str(row.get("faceName", "")).strip()
|
||||
name_key = str(row.get("name", "")).strip()
|
||||
keys = {k for k in (face_key, name_key) if k}
|
||||
if not keys:
|
||||
continue
|
||||
|
||||
for key in keys:
|
||||
bucket = combined.setdefault(key, {col: set() for col in columns})
|
||||
for col in columns:
|
||||
if col not in row:
|
||||
continue
|
||||
values = _coerce_tag_list(row.get(col))
|
||||
if values:
|
||||
bucket[col].update(values)
|
||||
|
||||
enriched: Dict[str, Dict[str, List[str]]] = {}
|
||||
for key, data in combined.items():
|
||||
enriched[key] = {col: sorted(values) for col, values in data.items() if values}
|
||||
return enriched
|
||||
|
||||
|
||||
def enrich_commander_rows_with_tags(
|
||||
df: pd.DataFrame,
|
||||
csv_dir: Union[str, Path],
|
||||
) -> pd.DataFrame:
|
||||
"""Attach theme and creature tag metadata to commander rows when available."""
|
||||
|
||||
if df.empty:
|
||||
df = df.copy()
|
||||
for column in ("themeTags", "creatureTypes", "roleTags"):
|
||||
if column not in df.columns:
|
||||
df[column] = []
|
||||
return df
|
||||
|
||||
metadata = _collect_commander_tag_metadata(csv_dir)
|
||||
if not metadata:
|
||||
df = df.copy()
|
||||
for column in ("themeTags", "creatureTypes", "roleTags"):
|
||||
if column not in df.columns:
|
||||
df[column] = [[] for _ in range(len(df))]
|
||||
return df
|
||||
|
||||
df = df.copy()
|
||||
for column in ("themeTags", "creatureTypes", "roleTags"):
|
||||
if column not in df.columns:
|
||||
df[column] = [[] for _ in range(len(df))]
|
||||
|
||||
theme_values: List[List[str]] = []
|
||||
creature_values: List[List[str]] = []
|
||||
role_values: List[List[str]] = []
|
||||
|
||||
for _, row in df.iterrows():
|
||||
face_key = str(row.get("faceName", "")).strip()
|
||||
name_key = str(row.get("name", "")).strip()
|
||||
|
||||
entry_face = metadata.get(face_key, {})
|
||||
entry_name = metadata.get(name_key, {})
|
||||
|
||||
combined: Dict[str, set[str]] = {
|
||||
"themeTags": set(_coerce_tag_list(row.get("themeTags"))),
|
||||
"creatureTypes": set(_coerce_tag_list(row.get("creatureTypes"))),
|
||||
"roleTags": set(_coerce_tag_list(row.get("roleTags"))),
|
||||
}
|
||||
|
||||
for source in (entry_face, entry_name):
|
||||
for column in combined:
|
||||
combined[column].update(source.get(column, []))
|
||||
|
||||
theme_values.append(sorted(combined["themeTags"]))
|
||||
creature_values.append(sorted(combined["creatureTypes"]))
|
||||
role_values.append(sorted(combined["roleTags"]))
|
||||
|
||||
df["themeTags"] = theme_values
|
||||
df["creatureTypes"] = creature_values
|
||||
df["roleTags"] = role_values
|
||||
|
||||
enriched_rows = sum(1 for t, c, r in zip(theme_values, creature_values, role_values) if t or c or r)
|
||||
logger.debug("Enriched %d commander rows with tag metadata", enriched_rows)
|
||||
|
||||
return df
|
||||
|
||||
# Type definitions
|
||||
class FilterRule(TypedDict):
|
||||
"""Type definition for filter rules configuration."""
|
||||
|
|
@ -194,13 +442,36 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
|
|||
filtered_df = df.copy()
|
||||
filter_config: FilterConfig = FILTER_CONFIG # Type hint for configuration
|
||||
for field, rules in filter_config.items():
|
||||
if field not in filtered_df.columns:
|
||||
logger.warning('Skipping filter for missing field %s', field)
|
||||
continue
|
||||
|
||||
for rule_type, values in rules.items():
|
||||
if not values:
|
||||
continue
|
||||
|
||||
if rule_type == 'exclude':
|
||||
for value in values:
|
||||
filtered_df = filtered_df[~filtered_df[field].str.contains(value, na=False)]
|
||||
mask = filtered_df[field].astype(str).str.contains(
|
||||
value,
|
||||
case=False,
|
||||
na=False,
|
||||
regex=False
|
||||
)
|
||||
filtered_df = filtered_df[~mask]
|
||||
elif rule_type == 'require':
|
||||
for value in values:
|
||||
filtered_df = filtered_df[filtered_df[field].str.contains(value, na=False)]
|
||||
mask = filtered_df[field].astype(str).str.contains(
|
||||
value,
|
||||
case=False,
|
||||
na=False,
|
||||
regex=False
|
||||
)
|
||||
filtered_df = filtered_df[mask]
|
||||
else:
|
||||
logger.warning('Unknown filter rule type %s for field %s', rule_type, field)
|
||||
continue
|
||||
|
||||
logger.debug(f'Applied {rule_type} filter for {field}: {values}')
|
||||
|
||||
# Remove illegal sets
|
||||
|
|
@ -406,7 +677,9 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
|||
"set_legality",
|
||||
str(e)
|
||||
) from e
|
||||
logger.info(f'Commander validation complete. {len(filtered_df)} valid commanders found')
|
||||
filtered_df = _enforce_primary_face_commander_rules(filtered_df, df)
|
||||
|
||||
logger.info('Commander validation complete. %d valid commanders found', len(filtered_df))
|
||||
return filtered_df
|
||||
|
||||
except CommanderValidationError:
|
||||
|
|
|
|||
|
|
@ -1546,7 +1546,11 @@ def _main() -> int:
|
|||
print("Error: commander is required. Provide --commander or a JSON config with a 'commander' field.")
|
||||
return 2
|
||||
|
||||
run(**resolved)
|
||||
try:
|
||||
run(**resolved)
|
||||
except CommanderValidationError as exc:
|
||||
print(str(exc))
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
305
code/scripts/preview_dfc_catalog_diff.py
Normal file
305
code/scripts/preview_dfc_catalog_diff.py
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
"""Catalog diff helper for verifying multi-face merge output.
|
||||
|
||||
This utility regenerates the card CSV catalog (optionally writing compatibility
|
||||
snapshots) and then compares the merged outputs against the baseline snapshots.
|
||||
It is intended to support the MDFC rollout checklist by providing a concise summary
|
||||
of how many rows were merged, which cards collapsed into a single record, and
|
||||
whether any tag unions diverge from expectations.
|
||||
|
||||
Example usage (from repo root, inside virtualenv):
|
||||
|
||||
python -m code.scripts.preview_dfc_catalog_diff --compat-snapshot --output logs/dfc_catalog_diff.json
|
||||
|
||||
The script prints a human readable summary to stdout and optionally writes a JSON
|
||||
artifact for release/staging review.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Sequence
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from settings import COLORS, CSV_DIRECTORY
|
||||
|
||||
DEFAULT_COMPAT_DIR = Path(os.getenv("DFC_COMPAT_DIR", "csv_files/compat_faces"))
|
||||
CSV_ROOT = Path(CSV_DIRECTORY)
|
||||
|
||||
|
||||
def _parse_list_cell(value: Any) -> List[str]:
|
||||
"""Convert serialized list cells ("['A', 'B']") into Python lists."""
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value]
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, float) and pd.isna(value): # type: ignore[arg-type]
|
||||
return []
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
parsed = ast.literal_eval(text)
|
||||
except (SyntaxError, ValueError):
|
||||
return [text]
|
||||
if isinstance(parsed, list):
|
||||
return [str(item) for item in parsed]
|
||||
return [str(parsed)]
|
||||
|
||||
|
||||
def _load_catalog(path: Path) -> pd.DataFrame:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Catalog file missing: {path}")
|
||||
df = pd.read_csv(path)
|
||||
for column in ("themeTags", "keywords", "creatureTypes"):
|
||||
if column in df.columns:
|
||||
df[column] = df[column].apply(_parse_list_cell)
|
||||
return df
|
||||
|
||||
|
||||
def _multi_face_names(df: pd.DataFrame) -> List[str]:
|
||||
counts = Counter(df.get("name", []))
|
||||
return [name for name, count in counts.items() if isinstance(name, str) and count > 1]
|
||||
|
||||
|
||||
def _collect_tags(series: Iterable[List[str]]) -> List[str]:
|
||||
tags: List[str] = []
|
||||
for value in series:
|
||||
if isinstance(value, list):
|
||||
tags.extend(str(item) for item in value)
|
||||
return sorted(set(tags))
|
||||
|
||||
|
||||
def _summarize_color(
|
||||
color: str,
|
||||
merged: pd.DataFrame,
|
||||
baseline: pd.DataFrame,
|
||||
sample_size: int,
|
||||
) -> Dict[str, Any]:
|
||||
merged_names = set(merged.get("name", []))
|
||||
baseline_names = list(baseline.get("name", []))
|
||||
baseline_name_set = set(name for name in baseline_names if isinstance(name, str))
|
||||
|
||||
multi_face = _multi_face_names(baseline)
|
||||
collapsed = []
|
||||
tag_mismatches: List[str] = []
|
||||
missing_after_merge: List[str] = []
|
||||
|
||||
for name in multi_face:
|
||||
group = baseline[baseline["name"] == name]
|
||||
merged_row = merged[merged["name"] == name]
|
||||
if merged_row.empty:
|
||||
missing_after_merge.append(name)
|
||||
continue
|
||||
expected_tags = _collect_tags(group["themeTags"]) if "themeTags" in group else []
|
||||
merged_tags = _collect_tags(merged_row.iloc[[0]]["themeTags"]) if "themeTags" in merged_row else []
|
||||
if expected_tags != merged_tags:
|
||||
tag_mismatches.append(name)
|
||||
collapsed.append(name)
|
||||
|
||||
removed_names = sorted(baseline_name_set - merged_names)
|
||||
added_names = sorted(merged_names - baseline_name_set)
|
||||
|
||||
return {
|
||||
"rows_merged": len(merged),
|
||||
"rows_baseline": len(baseline),
|
||||
"row_delta": len(merged) - len(baseline),
|
||||
"multi_face_groups": len(multi_face),
|
||||
"collapsed_sample": collapsed[:sample_size],
|
||||
"tag_union_mismatches": tag_mismatches[:sample_size],
|
||||
"missing_after_merge": missing_after_merge[:sample_size],
|
||||
"removed_names": removed_names[:sample_size],
|
||||
"added_names": added_names[:sample_size],
|
||||
}
|
||||
|
||||
|
||||
def _refresh_catalog(colors: Sequence[str], compat_snapshot: bool) -> None:
|
||||
os.environ.pop("ENABLE_DFC_MERGE", None)
|
||||
os.environ["DFC_COMPAT_SNAPSHOT"] = "1" if compat_snapshot else "0"
|
||||
importlib.invalidate_caches()
|
||||
# Reload tagger to pick up the new env var
|
||||
tagger = importlib.import_module("code.tagging.tagger")
|
||||
tagger = importlib.reload(tagger) # type: ignore[assignment]
|
||||
|
||||
for color in colors:
|
||||
tagger.load_dataframe(color)
|
||||
|
||||
|
||||
def generate_diff(
|
||||
colors: Sequence[str],
|
||||
compat_dir: Path,
|
||||
sample_size: int,
|
||||
) -> Dict[str, Any]:
|
||||
per_color: Dict[str, Any] = {}
|
||||
overall = {
|
||||
"total_rows_merged": 0,
|
||||
"total_rows_baseline": 0,
|
||||
"total_multi_face_groups": 0,
|
||||
"colors": len(colors),
|
||||
"tag_union_mismatches": 0,
|
||||
"missing_after_merge": 0,
|
||||
}
|
||||
|
||||
for color in colors:
|
||||
merged_path = CSV_ROOT / f"{color}_cards.csv"
|
||||
baseline_path = compat_dir / f"{color}_cards_unmerged.csv"
|
||||
merged_df = _load_catalog(merged_path)
|
||||
baseline_df = _load_catalog(baseline_path)
|
||||
summary = _summarize_color(color, merged_df, baseline_df, sample_size)
|
||||
per_color[color] = summary
|
||||
overall["total_rows_merged"] += summary["rows_merged"]
|
||||
overall["total_rows_baseline"] += summary["rows_baseline"]
|
||||
overall["total_multi_face_groups"] += summary["multi_face_groups"]
|
||||
overall["tag_union_mismatches"] += len(summary["tag_union_mismatches"])
|
||||
overall["missing_after_merge"] += len(summary["missing_after_merge"])
|
||||
|
||||
overall["row_delta_total"] = overall["total_rows_merged"] - overall["total_rows_baseline"]
|
||||
return {"overall": overall, "per_color": per_color}
|
||||
|
||||
|
||||
def main(argv: List[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description="Preview merged vs baseline DFC catalog diff")
|
||||
parser.add_argument(
|
||||
"--skip-refresh",
|
||||
action="store_true",
|
||||
help="Skip rebuilding the catalog in compatibility mode (requires existing compat snapshots)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
default="",
|
||||
help="[Deprecated] Legacy ENABLE_DFC_MERGE value (compat|1|0 etc.)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--compat-snapshot",
|
||||
dest="compat_snapshot",
|
||||
action="store_true",
|
||||
help="Write compatibility snapshots before diffing (default: off unless legacy --mode compat)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-compat-snapshot",
|
||||
dest="compat_snapshot",
|
||||
action="store_false",
|
||||
help="Skip compatibility snapshots even if legacy --mode compat is supplied",
|
||||
)
|
||||
parser.set_defaults(compat_snapshot=None)
|
||||
parser.add_argument(
|
||||
"--colors",
|
||||
nargs="*",
|
||||
help="Optional subset of colors to diff (defaults to full COLORS list)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--compat-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_COMPAT_DIR,
|
||||
help="Directory containing unmerged compatibility snapshots (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
help="Optional JSON file to write with the diff summary",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample-size",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Number of sample entries to include per section (default: %(default)s)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
colors = tuple(args.colors) if args.colors else tuple(COLORS)
|
||||
compat_dir = args.compat_dir
|
||||
|
||||
mode = str(args.mode or "").strip().lower()
|
||||
if mode and mode not in {"compat", "dual", "both", "1", "on", "true", "0", "off", "false", "disabled"}:
|
||||
print(
|
||||
f"ℹ Legacy --mode value '{mode}' detected; merge remains enabled. Use --compat-snapshot as needed.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if args.compat_snapshot is None:
|
||||
compat_snapshot = mode in {"compat", "dual", "both"}
|
||||
else:
|
||||
compat_snapshot = args.compat_snapshot
|
||||
if mode:
|
||||
print(
|
||||
"ℹ Ignoring deprecated --mode value because --compat-snapshot/--no-compat-snapshot was supplied.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if mode in {"0", "off", "false", "disabled"}:
|
||||
print(
|
||||
"⚠ ENABLE_DFC_MERGE=off is deprecated; the merge remains enabled regardless of the value.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if not args.skip_refresh:
|
||||
start = time.perf_counter()
|
||||
_refresh_catalog(colors, compat_snapshot)
|
||||
duration = time.perf_counter() - start
|
||||
snapshot_msg = "with compat snapshot" if compat_snapshot else "merged-only"
|
||||
print(f"✔ Refreshed catalog in {duration:.1f}s ({snapshot_msg})")
|
||||
else:
|
||||
print("ℹ Using existing catalog outputs (refresh skipped)")
|
||||
|
||||
try:
|
||||
diff = generate_diff(colors, compat_dir, args.sample_size)
|
||||
except FileNotFoundError as exc:
|
||||
print(f"ERROR: {exc}")
|
||||
print("Run without --skip-refresh (or ensure compat snapshots exist).", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
overall = diff["overall"]
|
||||
print("\n=== DFC Catalog Diff Summary ===")
|
||||
print(
|
||||
f"Merged rows: {overall['total_rows_merged']:,} | Baseline rows: {overall['total_rows_baseline']:,} | "
|
||||
f"Δ rows: {overall['row_delta_total']:,}"
|
||||
)
|
||||
print(
|
||||
f"Multi-face groups: {overall['total_multi_face_groups']:,} | "
|
||||
f"Tag union mismatches: {overall['tag_union_mismatches']} | Missing after merge: {overall['missing_after_merge']}"
|
||||
)
|
||||
|
||||
for color, summary in diff["per_color"].items():
|
||||
print(f"\n[{color}] baseline={summary['rows_baseline']} merged={summary['rows_merged']} Δ={summary['row_delta']}")
|
||||
if summary["multi_face_groups"]:
|
||||
print(f" multi-face groups: {summary['multi_face_groups']}")
|
||||
if summary["collapsed_sample"]:
|
||||
sample = ", ".join(summary["collapsed_sample"][:3])
|
||||
print(f" collapsed sample: {sample}")
|
||||
if summary["tag_union_mismatches"]:
|
||||
print(f" TAG MISMATCH sample: {', '.join(summary['tag_union_mismatches'])}")
|
||||
if summary["missing_after_merge"]:
|
||||
print(f" MISSING sample: {', '.join(summary['missing_after_merge'])}")
|
||||
if summary["removed_names"]:
|
||||
print(f" removed sample: {', '.join(summary['removed_names'])}")
|
||||
if summary["added_names"]:
|
||||
print(f" added sample: {', '.join(summary['added_names'])}")
|
||||
|
||||
if args.output:
|
||||
payload = {
|
||||
"captured_at": int(time.time()),
|
||||
"mode": args.mode,
|
||||
"colors": colors,
|
||||
"compat_dir": str(compat_dir),
|
||||
"summary": diff,
|
||||
}
|
||||
try:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
print(f"\n📄 Wrote JSON summary to {args.output}")
|
||||
except Exception as exc: # pragma: no cover
|
||||
print(f"Failed to write output file {args.output}: {exc}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
126
code/scripts/refresh_commander_catalog.py
Normal file
126
code/scripts/refresh_commander_catalog.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Regenerate commander catalog with MDFC merge applied.
|
||||
|
||||
This helper refreshes `commander_cards.csv` using the latest setup pipeline and
|
||||
then runs the tagging/merge step so downstream consumers pick up the unified
|
||||
multi-face rows. The merge is now always enabled; use the optional
|
||||
`--compat-snapshot` flag to emit an unmerged compatibility snapshot alongside
|
||||
the merged catalog for downstream validation.
|
||||
|
||||
Examples (run from repo root after activating the virtualenv):
|
||||
|
||||
python -m code.scripts.refresh_commander_catalog
|
||||
python -m code.scripts.refresh_commander_catalog --compat-snapshot --skip-setup
|
||||
|
||||
The legacy `--mode` argument is retained for backwards compatibility but no
|
||||
longer disables the merge. `--mode compat` is treated the same as
|
||||
`--compat-snapshot`, while `--mode off` now issues a warning and still runs the
|
||||
merge.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from settings import CSV_DIRECTORY
|
||||
|
||||
DEFAULT_COMPAT_SNAPSHOT = False
|
||||
SUPPORTED_COLORS = ("commander",)
|
||||
|
||||
|
||||
def _refresh_setup() -> None:
|
||||
setup_mod = importlib.import_module("code.file_setup.setup")
|
||||
setup_mod.determine_commanders()
|
||||
|
||||
|
||||
def _refresh_tags() -> None:
|
||||
tagger = importlib.import_module("code.tagging.tagger")
|
||||
tagger = importlib.reload(tagger) # type: ignore[assignment]
|
||||
for color in SUPPORTED_COLORS:
|
||||
tagger.load_dataframe(color)
|
||||
|
||||
|
||||
def _summarize_outputs(compat_snapshot: bool) -> str:
|
||||
merged = Path(CSV_DIRECTORY) / "commander_cards.csv"
|
||||
compat_dir = Path(os.getenv("DFC_COMPAT_DIR", "csv_files/compat_faces"))
|
||||
parts = ["✔ Commander catalog refreshed (multi-face merge always on)"]
|
||||
parts.append(f" merged file: {merged.resolve()}")
|
||||
if compat_snapshot:
|
||||
compat_path = compat_dir / "commander_cards_unmerged.csv"
|
||||
parts.append(f" compat snapshot: {compat_path.resolve()}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _resolve_compat_snapshot(mode: str, cli_override: bool | None) -> bool:
|
||||
"""Determine whether to write the compatibility snapshot."""
|
||||
|
||||
if cli_override is not None:
|
||||
return cli_override
|
||||
|
||||
normalized = str(mode or "").strip().lower()
|
||||
|
||||
if normalized in {"", "1", "true", "on"}:
|
||||
return False
|
||||
if normalized in {"compat", "dual", "both"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "off", "disabled"}:
|
||||
print(
|
||||
"⚠ ENABLE_DFC_MERGE=off is deprecated; the merge remains enabled and no compatibility snapshot is written by default.",
|
||||
flush=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if normalized:
|
||||
print(
|
||||
f"ℹ Legacy --mode value '{normalized}' detected. Multi-face merge is always enabled; pass --compat-snapshot to write the unmerged CSV.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return DEFAULT_COMPAT_SNAPSHOT
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description="Refresh commander catalog with MDFC merge")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
default="",
|
||||
help="[Deprecated] Legacy ENABLE_DFC_MERGE value (compat|1|0 etc.).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-setup",
|
||||
action="store_true",
|
||||
help="Skip the setup.determine_commanders() step if commander_cards.csv is already up to date.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--compat-snapshot",
|
||||
dest="compat_snapshot",
|
||||
action="store_true",
|
||||
help="Write compatibility snapshots to csv_files/compat_faces/commander_cards_unmerged.csv",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-compat-snapshot",
|
||||
dest="compat_snapshot",
|
||||
action="store_false",
|
||||
help="Skip writing compatibility snapshots (default).",
|
||||
)
|
||||
parser.set_defaults(compat_snapshot=None)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
compat_snapshot = _resolve_compat_snapshot(str(args.mode or ""), args.compat_snapshot)
|
||||
os.environ.pop("ENABLE_DFC_MERGE", None)
|
||||
os.environ["DFC_COMPAT_SNAPSHOT"] = "1" if compat_snapshot else "0"
|
||||
importlib.invalidate_caches()
|
||||
|
||||
if not args.skip_setup:
|
||||
_refresh_setup()
|
||||
|
||||
_refresh_tags()
|
||||
|
||||
print(_summarize_outputs(compat_snapshot))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
304
code/tagging/multi_face_merger.py
Normal file
304
code/tagging/multi_face_merger.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""Utilities for merging multi-faced card entries after tagging.
|
||||
|
||||
This module groups card DataFrame rows that represent multiple faces of the same
|
||||
card (transform, split, adventure, modal DFC, etc.) and collapses them into a
|
||||
single canonical record with merged tags.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, List, Sequence, Set
|
||||
|
||||
import pandas as pd
|
||||
|
||||
# Layouts that indicate a card has multiple faces represented as separate rows.
|
||||
_MULTI_FACE_LAYOUTS: Set[str] = {
|
||||
"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,
|
||||
}
|
||||
|
||||
_LIST_UNION_COLUMNS: Sequence[str] = ("themeTags", "creatureTypes", "roleTags")
|
||||
|
||||
_SUMMARY_PATH = Path("logs/dfc_merge_summary.json")
|
||||
|
||||
|
||||
def _text_produces_mana(text: Any) -> bool:
|
||||
text_str = str(text or "").lower()
|
||||
if not text_str:
|
||||
return False
|
||||
if "add one mana of any color" in text_str or "add one mana of any colour" in text_str:
|
||||
return True
|
||||
if "add mana of any color" in text_str or "add mana of any colour" in text_str:
|
||||
return True
|
||||
if "mana of any one color" in text_str or "any color of mana" in text_str:
|
||||
return True
|
||||
if "add" in text_str:
|
||||
for sym in ("{w}", "{u}", "{b}", "{r}", "{g}", "{c}"):
|
||||
if sym in text_str:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def load_merge_summary() -> Dict[str, Any]:
|
||||
try:
|
||||
with _SUMMARY_PATH.open("r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
return {"updated_at": None, "colors": {}}
|
||||
|
||||
|
||||
def merge_multi_face_rows(
|
||||
df: pd.DataFrame,
|
||||
color: str,
|
||||
logger=None,
|
||||
recorder: Callable[[Dict[str, Any]], None] | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Merge multi-face card rows into canonical entries with combined tags.
|
||||
|
||||
Args:
|
||||
df: DataFrame containing tagged card data for a specific color.
|
||||
color: Color name, used for logging context.
|
||||
logger: Optional logger instance. When provided, debug information is emitted.
|
||||
|
||||
Returns:
|
||||
DataFrame with multi-face entries collapsed and combined tag data.
|
||||
"""
|
||||
if df.empty or "layout" not in df.columns or "name" not in df.columns:
|
||||
return df
|
||||
|
||||
work_df = df.copy()
|
||||
|
||||
layout_series = work_df["layout"].fillna("").astype(str).str.lower()
|
||||
multi_mask = layout_series.isin(_MULTI_FACE_LAYOUTS)
|
||||
|
||||
if not multi_mask.any():
|
||||
return work_df
|
||||
|
||||
drop_indices: List[int] = []
|
||||
merged_count = 0
|
||||
merge_details: List[Dict[str, Any]] = []
|
||||
|
||||
for name, group in work_df.loc[multi_mask].groupby("name", sort=False):
|
||||
if len(group) <= 1:
|
||||
continue
|
||||
|
||||
group_sorted = _sort_faces(group)
|
||||
primary_idx = group_sorted.index[0]
|
||||
faces_payload: List[Dict[str, Any]] = []
|
||||
|
||||
for column in _LIST_UNION_COLUMNS:
|
||||
if column in group_sorted.columns:
|
||||
union_values = _merge_object_lists(group_sorted[column])
|
||||
work_df.at[primary_idx, column] = union_values
|
||||
|
||||
if "keywords" in group_sorted.columns:
|
||||
keyword_union = _merge_keywords(group_sorted["keywords"])
|
||||
work_df.at[primary_idx, "keywords"] = _join_keywords(keyword_union)
|
||||
|
||||
for _, face_row in group_sorted.iterrows():
|
||||
text_val = face_row.get("text") or face_row.get("oracleText") or ""
|
||||
mana_cost_val = face_row.get("manaCost", face_row.get("mana_cost", "")) or ""
|
||||
mana_value_raw = face_row.get("manaValue", face_row.get("mana_value", ""))
|
||||
try:
|
||||
if mana_value_raw in (None, ""):
|
||||
mana_value_val = None
|
||||
else:
|
||||
mana_value_val = float(mana_value_raw)
|
||||
if math.isnan(mana_value_val):
|
||||
mana_value_val = None
|
||||
except Exception:
|
||||
mana_value_val = None
|
||||
type_val = face_row.get("type", "") or ""
|
||||
faces_payload.append(
|
||||
{
|
||||
"face": str(face_row.get("faceName") or face_row.get("name") or ""),
|
||||
"side": str(face_row.get("side") or ""),
|
||||
"layout": str(face_row.get("layout") or ""),
|
||||
"themeTags": _merge_object_lists([face_row.get("themeTags", [])]),
|
||||
"roleTags": _merge_object_lists([face_row.get("roleTags", [])]),
|
||||
"type": str(type_val),
|
||||
"text": str(text_val),
|
||||
"mana_cost": str(mana_cost_val),
|
||||
"mana_value": mana_value_val,
|
||||
"produces_mana": _text_produces_mana(text_val),
|
||||
"is_land": 'land' in str(type_val).lower(),
|
||||
}
|
||||
)
|
||||
|
||||
for idx in group_sorted.index[1:]:
|
||||
drop_indices.append(idx)
|
||||
|
||||
merged_count += 1
|
||||
layout_set = sorted({f.get("layout", "") for f in faces_payload if f.get("layout")})
|
||||
removed_faces = faces_payload[1:] if len(faces_payload) > 1 else []
|
||||
merge_details.append(
|
||||
{
|
||||
"name": name,
|
||||
"total_faces": len(group_sorted),
|
||||
"dropped_faces": max(len(group_sorted) - 1, 0),
|
||||
"layouts": layout_set,
|
||||
"primary_face": faces_payload[0] if faces_payload else {},
|
||||
"removed_faces": removed_faces,
|
||||
"theme_tags": sorted({tag for face in faces_payload for tag in face.get("themeTags", [])}),
|
||||
"role_tags": sorted({tag for face in faces_payload for tag in face.get("roleTags", [])}),
|
||||
"faces": faces_payload,
|
||||
}
|
||||
)
|
||||
|
||||
if drop_indices:
|
||||
work_df = work_df.drop(index=drop_indices)
|
||||
|
||||
summary_payload = {
|
||||
"color": color,
|
||||
"group_count": merged_count,
|
||||
"faces_dropped": len(drop_indices),
|
||||
"multi_face_rows": int(multi_mask.sum()),
|
||||
"entries": merge_details,
|
||||
}
|
||||
|
||||
if recorder is not None:
|
||||
try:
|
||||
maybe_payload = recorder(summary_payload)
|
||||
if isinstance(maybe_payload, dict):
|
||||
summary_payload = maybe_payload
|
||||
except Exception as exc:
|
||||
if logger is not None:
|
||||
logger.warning("Failed to record DFC merge summary for %s: %s", color, exc)
|
||||
|
||||
if logger is not None:
|
||||
try:
|
||||
logger.info(
|
||||
"dfc_merge_summary %s",
|
||||
json.dumps(
|
||||
{
|
||||
"event": "dfc_merge_summary",
|
||||
"color": color,
|
||||
"groups_merged": merged_count,
|
||||
"faces_dropped": len(drop_indices),
|
||||
"multi_face_rows": int(multi_mask.sum()),
|
||||
},
|
||||
sort_keys=True,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logger.info(
|
||||
"dfc_merge_summary event=%s groups=%d dropped=%d rows=%d",
|
||||
color,
|
||||
merged_count,
|
||||
len(drop_indices),
|
||||
int(multi_mask.sum()),
|
||||
)
|
||||
logger.info(
|
||||
"Merged %d multi-face card groups for %s (dropped %d extra faces)",
|
||||
merged_count,
|
||||
color,
|
||||
len(drop_indices),
|
||||
)
|
||||
|
||||
_persist_merge_summary(color, summary_payload, logger)
|
||||
|
||||
# Reset index to keep downstream expectations consistent.
|
||||
return work_df.reset_index(drop=True)
|
||||
|
||||
|
||||
def _persist_merge_summary(color: str, summary_payload: Dict[str, Any], logger=None) -> None:
|
||||
try:
|
||||
_SUMMARY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = load_merge_summary()
|
||||
colors = existing.get("colors")
|
||||
if not isinstance(colors, dict):
|
||||
colors = {}
|
||||
summary_payload = dict(summary_payload)
|
||||
timestamp = datetime.now(UTC).isoformat(timespec="seconds")
|
||||
summary_payload["timestamp"] = timestamp
|
||||
colors[color] = summary_payload
|
||||
existing["colors"] = colors
|
||||
existing["updated_at"] = timestamp
|
||||
with _SUMMARY_PATH.open("w", encoding="utf-8") as handle:
|
||||
json.dump(existing, handle, indent=2, sort_keys=True)
|
||||
except Exception as exc:
|
||||
if logger is not None:
|
||||
logger.warning("Failed to persist DFC merge summary: %s", exc)
|
||||
|
||||
|
||||
def _sort_faces(group: pd.DataFrame) -> pd.DataFrame:
|
||||
side_series = group.get("side", pd.Series(["" for _ in range(len(group))], index=group.index))
|
||||
priority = side_series.fillna("").astype(str).str.lower().map(_SIDE_PRIORITY).fillna(3)
|
||||
return group.assign(__face_order=priority).sort_values(
|
||||
by=["__face_order", "faceName"], kind="mergesort"
|
||||
).drop(columns=["__face_order"], errors="ignore")
|
||||
|
||||
|
||||
def _merge_object_lists(values: Iterable[Any]) -> List[str]:
|
||||
merged: Set[str] = set()
|
||||
for value in values:
|
||||
merged.update(_coerce_list(value))
|
||||
return sorted(merged)
|
||||
|
||||
|
||||
def _merge_keywords(values: Iterable[Any]) -> Set[str]:
|
||||
merged: Set[str] = set()
|
||||
for value in values:
|
||||
merged.update(_split_keywords(value))
|
||||
return merged
|
||||
|
||||
|
||||
def _join_keywords(keywords: Set[str]) -> str:
|
||||
if not keywords:
|
||||
return ""
|
||||
return ", ".join(sorted(keywords))
|
||||
|
||||
|
||||
def _coerce_list(value: Any) -> List[str]:
|
||||
if isinstance(value, list):
|
||||
return [str(v) for v in value if str(v)]
|
||||
if value is None or (isinstance(value, float) and pd.isna(value)):
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return []
|
||||
try:
|
||||
parsed = ast.literal_eval(stripped)
|
||||
except (ValueError, SyntaxError):
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
return [str(v) for v in parsed if str(v)]
|
||||
return [part for part in (s.strip() for s in stripped.split(',')) if part]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def _split_keywords(value: Any) -> Set[str]:
|
||||
if value is None or (isinstance(value, float) and pd.isna(value)):
|
||||
return set()
|
||||
if isinstance(value, list):
|
||||
return {str(v).strip() for v in value if str(v).strip()}
|
||||
if isinstance(value, str):
|
||||
return {part.strip() for part in value.split(',') if part.strip()}
|
||||
return {str(value).strip()}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
# Standard library imports
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Union
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
# Third-party imports
|
||||
import pandas as pd
|
||||
|
|
@ -12,9 +15,11 @@ import pandas as pd
|
|||
from . import tag_utils
|
||||
from . import tag_constants
|
||||
from .bracket_policy_applier import apply_bracket_policy_tags
|
||||
from .multi_face_merger import merge_multi_face_rows
|
||||
from settings import CSV_DIRECTORY, MULTIPLE_COPY_CARDS, COLORS
|
||||
import logging_util
|
||||
from file_setup import setup
|
||||
from file_setup.setup_utils import enrich_commander_rows_with_tags
|
||||
|
||||
# Create logger for this module
|
||||
logger = logging_util.logging.getLogger(__name__)
|
||||
|
|
@ -22,6 +27,138 @@ logger.setLevel(logging_util.LOG_LEVEL)
|
|||
logger.addHandler(logging_util.file_handler)
|
||||
logger.addHandler(logging_util.stream_handler)
|
||||
|
||||
_MERGE_FLAG_RAW = str(os.getenv("ENABLE_DFC_MERGE", "") or "").strip().lower()
|
||||
if _MERGE_FLAG_RAW in {"0", "false", "off", "disabled"}:
|
||||
logger.warning(
|
||||
"ENABLE_DFC_MERGE=%s is deprecated and no longer disables the merge; multi-face merge is always enabled.",
|
||||
_MERGE_FLAG_RAW,
|
||||
)
|
||||
elif _MERGE_FLAG_RAW:
|
||||
logger.info(
|
||||
"ENABLE_DFC_MERGE=%s detected (deprecated); multi-face merge now runs unconditionally.",
|
||||
_MERGE_FLAG_RAW,
|
||||
)
|
||||
|
||||
_COMPAT_FLAG_RAW = os.getenv("DFC_COMPAT_SNAPSHOT")
|
||||
if _COMPAT_FLAG_RAW is not None:
|
||||
_COMPAT_FLAG_NORMALIZED = str(_COMPAT_FLAG_RAW or "").strip().lower()
|
||||
DFC_COMPAT_SNAPSHOT = _COMPAT_FLAG_NORMALIZED not in {"0", "false", "off", "disabled"}
|
||||
else:
|
||||
DFC_COMPAT_SNAPSHOT = _MERGE_FLAG_RAW in {"compat", "dual", "both"}
|
||||
|
||||
_DFC_COMPAT_DIR = Path(os.getenv("DFC_COMPAT_DIR", "csv_files/compat_faces"))
|
||||
|
||||
_PER_FACE_SNAPSHOT_RAW = os.getenv("DFC_PER_FACE_SNAPSHOT")
|
||||
if _PER_FACE_SNAPSHOT_RAW is not None:
|
||||
_PER_FACE_SNAPSHOT_NORMALIZED = str(_PER_FACE_SNAPSHOT_RAW or "").strip().lower()
|
||||
DFC_PER_FACE_SNAPSHOT = _PER_FACE_SNAPSHOT_NORMALIZED not in {"0", "false", "off", "disabled"}
|
||||
else:
|
||||
DFC_PER_FACE_SNAPSHOT = False
|
||||
|
||||
_DFC_PER_FACE_SNAPSHOT_PATH = Path(os.getenv("DFC_PER_FACE_SNAPSHOT_PATH", "logs/dfc_per_face_snapshot.json"))
|
||||
_PER_FACE_SNAPSHOT_BUFFER: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _record_per_face_snapshot(color: str, payload: Dict[str, Any]) -> None:
|
||||
if not DFC_PER_FACE_SNAPSHOT:
|
||||
return
|
||||
entries = payload.get("entries")
|
||||
if not isinstance(entries, list):
|
||||
return
|
||||
bucket = _PER_FACE_SNAPSHOT_BUFFER.setdefault(color, [])
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
faces_data = []
|
||||
raw_faces = entry.get("faces")
|
||||
if isinstance(raw_faces, list):
|
||||
for face in raw_faces:
|
||||
if isinstance(face, dict):
|
||||
faces_data.append({k: face.get(k) for k in (
|
||||
"face",
|
||||
"side",
|
||||
"layout",
|
||||
"type",
|
||||
"text",
|
||||
"mana_cost",
|
||||
"mana_value",
|
||||
"produces_mana",
|
||||
"is_land",
|
||||
"themeTags",
|
||||
"roleTags",
|
||||
)})
|
||||
else:
|
||||
faces_data.append(face)
|
||||
primary_face = entry.get("primary_face")
|
||||
if isinstance(primary_face, dict):
|
||||
primary_face_copy = dict(primary_face)
|
||||
else:
|
||||
primary_face_copy = primary_face
|
||||
removed_faces = entry.get("removed_faces")
|
||||
if isinstance(removed_faces, list):
|
||||
removed_faces_copy = [dict(face) if isinstance(face, dict) else face for face in removed_faces]
|
||||
else:
|
||||
removed_faces_copy = removed_faces
|
||||
bucket.append(
|
||||
{
|
||||
"name": entry.get("name"),
|
||||
"total_faces": entry.get("total_faces"),
|
||||
"dropped_faces": entry.get("dropped_faces"),
|
||||
"layouts": list(entry.get("layouts", [])) if isinstance(entry.get("layouts"), list) else entry.get("layouts"),
|
||||
"primary_face": primary_face_copy,
|
||||
"faces": faces_data,
|
||||
"removed_faces": removed_faces_copy,
|
||||
"theme_tags": entry.get("theme_tags"),
|
||||
"role_tags": entry.get("role_tags"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _flush_per_face_snapshot() -> None:
|
||||
if not DFC_PER_FACE_SNAPSHOT:
|
||||
_PER_FACE_SNAPSHOT_BUFFER.clear()
|
||||
return
|
||||
if not _PER_FACE_SNAPSHOT_BUFFER:
|
||||
return
|
||||
try:
|
||||
colors_payload = {color: list(entries) for color, entries in _PER_FACE_SNAPSHOT_BUFFER.items()}
|
||||
payload = {
|
||||
"generated_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
||||
"mode": "always_on",
|
||||
"compat_snapshot": bool(DFC_COMPAT_SNAPSHOT),
|
||||
"colors": colors_payload,
|
||||
}
|
||||
_DFC_PER_FACE_SNAPSHOT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _DFC_PER_FACE_SNAPSHOT_PATH.open("w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2, sort_keys=True)
|
||||
logger.info("Wrote per-face snapshot to %s", _DFC_PER_FACE_SNAPSHOT_PATH)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to write per-face snapshot: %s", exc)
|
||||
finally:
|
||||
_PER_FACE_SNAPSHOT_BUFFER.clear()
|
||||
|
||||
|
||||
def _merge_summary_recorder(color: str):
|
||||
def _recorder(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
enriched = dict(payload)
|
||||
enriched["mode"] = "always_on"
|
||||
enriched["compat_snapshot"] = bool(DFC_COMPAT_SNAPSHOT)
|
||||
if DFC_PER_FACE_SNAPSHOT:
|
||||
_record_per_face_snapshot(color, payload)
|
||||
return enriched
|
||||
|
||||
return _recorder
|
||||
|
||||
|
||||
def _write_compat_snapshot(df: pd.DataFrame, color: str) -> None:
|
||||
try: # type: ignore[name-defined]
|
||||
_DFC_COMPAT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = _DFC_COMPAT_DIR / f"{color}_cards_unmerged.csv"
|
||||
df.to_csv(path, index=False)
|
||||
logger.info("Wrote unmerged snapshot for %s to %s", color, path)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to write unmerged snapshot for %s: %s", color, exc)
|
||||
|
||||
### Setup
|
||||
## Load the dataframe
|
||||
def load_dataframe(color: str) -> None:
|
||||
|
|
@ -178,6 +315,18 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None:
|
|||
apply_bracket_policy_tags(df)
|
||||
print('\n====================\n')
|
||||
|
||||
# Merge multi-face entries before final ordering (feature-flagged)
|
||||
if DFC_COMPAT_SNAPSHOT:
|
||||
try:
|
||||
_write_compat_snapshot(df.copy(deep=True), color)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
df = merge_multi_face_rows(df, color, logger=logger, recorder=_merge_summary_recorder(color))
|
||||
|
||||
if color == 'commander':
|
||||
df = enrich_commander_rows_with_tags(df, CSV_DIRECTORY)
|
||||
|
||||
# Lastly, sort all theme tags for easier reading and reorder columns
|
||||
df = sort_theme_tags(df, color)
|
||||
df.to_csv(f'{CSV_DIRECTORY}/{color}_cards.csv', index=False)
|
||||
|
|
@ -6915,6 +7064,9 @@ def run_tagging(parallel: bool = False, max_workers: int | None = None):
|
|||
"""
|
||||
start_time = pd.Timestamp.now()
|
||||
|
||||
if parallel and DFC_PER_FACE_SNAPSHOT:
|
||||
logger.warning("DFC_PER_FACE_SNAPSHOT=1 detected; per-face metadata snapshots require sequential tagging. Parallel run will skip snapshot emission.")
|
||||
|
||||
if parallel:
|
||||
try:
|
||||
import concurrent.futures as _f
|
||||
|
|
@ -6937,5 +7089,6 @@ def run_tagging(parallel: bool = False, max_workers: int | None = None):
|
|||
for color in COLORS:
|
||||
load_dataframe(color)
|
||||
|
||||
_flush_per_face_snapshot()
|
||||
duration = (pd.Timestamp.now() - start_time).total_seconds()
|
||||
logger.info(f'Tagged cards in {duration:.2f}s')
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ def _fake_session(**kw):
|
|||
"prefer_combos": False,
|
||||
"combo_target_count": 2,
|
||||
"combo_balance": "mix",
|
||||
"swap_mdfc_basics": False,
|
||||
}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
|
@ -47,6 +48,7 @@ def test_start_ctx_from_session_minimal(monkeypatch):
|
|||
assert "builder" in ctx
|
||||
assert "stages" in ctx
|
||||
assert "idx" in ctx
|
||||
assert calls.get("swap_mdfc_basics") is False
|
||||
|
||||
|
||||
def test_start_ctx_from_session_sets_on_session(monkeypatch):
|
||||
|
|
|
|||
77
code/tests/test_commander_exclusion_warnings.py
Normal file
77
code/tests/test_commander_exclusion_warnings.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from code.web.app import app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> Iterator[TestClient]:
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_candidate_list_includes_exclusion_warning(monkeypatch: pytest.MonkeyPatch, client: TestClient) -> None:
|
||||
def fake_candidates(_: str, limit: int = 8):
|
||||
return [("Sample Front", 10, ["G"])]
|
||||
|
||||
def fake_lookup(name: str):
|
||||
if name == "Sample Front":
|
||||
return {
|
||||
"primary_face": "Sample Front",
|
||||
"eligible_faces": ["Sample Back"],
|
||||
"reason": "secondary_face_only",
|
||||
}
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("code.web.routes.build.orch.commander_candidates", fake_candidates)
|
||||
monkeypatch.setattr("code.web.routes.build.lookup_commander_detail", fake_lookup)
|
||||
|
||||
response = client.get("/build/new/candidates", params={"commander": "Sample"})
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "Use the back face 'Sample Back' when building" in body
|
||||
assert "data-name=\"Sample Back\"" in body
|
||||
assert "data-display=\"Sample Front\"" in body
|
||||
|
||||
|
||||
def test_front_face_submit_returns_modal_error(monkeypatch: pytest.MonkeyPatch, client: TestClient) -> None:
|
||||
def fake_lookup(name: str):
|
||||
if "Budoka" in name:
|
||||
return {
|
||||
"primary_face": "Budoka Gardener",
|
||||
"eligible_faces": ["Dokai, Weaver of Life"],
|
||||
"reason": "secondary_face_only",
|
||||
}
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("code.web.routes.build.lookup_commander_detail", fake_lookup)
|
||||
monkeypatch.setattr("code.web.routes.build.orch.bracket_options", lambda: [{"level": 3, "name": "Upgraded"}])
|
||||
monkeypatch.setattr("code.web.routes.build.orch.ideal_labels", lambda: {})
|
||||
monkeypatch.setattr("code.web.routes.build.orch.ideal_defaults", lambda: {})
|
||||
|
||||
def fail_select(name: str): # pragma: no cover - should not trigger
|
||||
raise AssertionError(f"commander_select should not be called for {name}")
|
||||
|
||||
monkeypatch.setattr("code.web.routes.build.orch.commander_select", fail_select)
|
||||
|
||||
client.get("/build")
|
||||
response = client.post(
|
||||
"/build/new",
|
||||
data={
|
||||
"name": "",
|
||||
"commander": "Budoka Gardener",
|
||||
"bracket": "3",
|
||||
"include_cards": "",
|
||||
"exclude_cards": "",
|
||||
"enforcement_mode": "warn",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "can't lead a deck" in body
|
||||
assert "Use 'Dokai, Weaver of Life' as the commander instead" in body
|
||||
assert "value=\"Dokai, Weaver of Life\"" in body
|
||||
221
code/tests/test_commander_primary_face_filter.py
Normal file
221
code/tests/test_commander_primary_face_filter.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import headless_runner as hr
|
||||
from exceptions import CommanderValidationError
|
||||
from file_setup import setup_utils as su
|
||||
from file_setup.setup_utils import filter_dataframe, process_legendary_cards
|
||||
import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_csv_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(su, "CSV_DIRECTORY", str(tmp_path))
|
||||
monkeypatch.setattr(settings, "CSV_DIRECTORY", str(tmp_path))
|
||||
import importlib
|
||||
|
||||
setup_module = importlib.import_module("file_setup.setup")
|
||||
monkeypatch.setattr(setup_module, "CSV_DIRECTORY", str(tmp_path))
|
||||
return Path(tmp_path)
|
||||
|
||||
|
||||
def _make_card_row(
|
||||
*,
|
||||
name: str,
|
||||
face_name: str,
|
||||
type_line: str,
|
||||
side: str | None,
|
||||
layout: str,
|
||||
text: str = "",
|
||||
power: str | None = None,
|
||||
toughness: str | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"faceName": face_name,
|
||||
"edhrecRank": 1000,
|
||||
"colorIdentity": "B",
|
||||
"colors": "B",
|
||||
"manaCost": "3B",
|
||||
"manaValue": 4,
|
||||
"type": type_line,
|
||||
"creatureTypes": "['Demon']" if "Creature" in type_line else "[]",
|
||||
"text": text,
|
||||
"power": power,
|
||||
"toughness": toughness,
|
||||
"keywords": "",
|
||||
"themeTags": "[]",
|
||||
"layout": layout,
|
||||
"side": side,
|
||||
"availability": "paper",
|
||||
"promoTypes": "",
|
||||
"securityStamp": "",
|
||||
"printings": "SET",
|
||||
}
|
||||
|
||||
|
||||
def test_secondary_face_only_commander_removed(tmp_csv_dir):
|
||||
name = "Elbrus, the Binding Blade // Withengar Unbound"
|
||||
df = pd.DataFrame(
|
||||
[
|
||||
_make_card_row(
|
||||
name=name,
|
||||
face_name="Elbrus, the Binding Blade",
|
||||
type_line="Legendary Artifact — Equipment",
|
||||
side="a",
|
||||
layout="transform",
|
||||
),
|
||||
_make_card_row(
|
||||
name=name,
|
||||
face_name="Withengar Unbound",
|
||||
type_line="Legendary Creature — Demon",
|
||||
side="b",
|
||||
layout="transform",
|
||||
power="13",
|
||||
toughness="13",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
processed = process_legendary_cards(df)
|
||||
assert processed.empty
|
||||
|
||||
exclusion_path = tmp_csv_dir / ".commander_exclusions.json"
|
||||
assert exclusion_path.exists(), "Expected commander exclusion diagnostics to be written"
|
||||
data = json.loads(exclusion_path.read_text(encoding="utf-8"))
|
||||
entries = data.get("secondary_face_only", [])
|
||||
assert any(entry.get("name") == name for entry in entries)
|
||||
|
||||
|
||||
def test_primary_face_retained_and_log_cleared(tmp_csv_dir):
|
||||
name = "Birgi, God of Storytelling // Harnfel, Horn of Bounty"
|
||||
df = pd.DataFrame(
|
||||
[
|
||||
_make_card_row(
|
||||
name=name,
|
||||
face_name="Birgi, God of Storytelling",
|
||||
type_line="Legendary Creature — God",
|
||||
side="a",
|
||||
layout="modal_dfc",
|
||||
power="3",
|
||||
toughness="3",
|
||||
),
|
||||
_make_card_row(
|
||||
name=name,
|
||||
face_name="Harnfel, Horn of Bounty",
|
||||
type_line="Legendary Artifact",
|
||||
side="b",
|
||||
layout="modal_dfc",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
processed = process_legendary_cards(df)
|
||||
assert len(processed) == 1
|
||||
assert processed.iloc[0]["faceName"] == "Birgi, God of Storytelling"
|
||||
|
||||
# Downstream filter should continue to succeed with a single primary row
|
||||
filtered = filter_dataframe(processed, [])
|
||||
assert len(filtered) == 1
|
||||
|
||||
exclusion_path = tmp_csv_dir / ".commander_exclusions.json"
|
||||
assert not exclusion_path.exists(), "No exclusion log expected when primary face remains"
|
||||
|
||||
|
||||
def test_headless_validation_reports_secondary_face(monkeypatch):
|
||||
monkeypatch.setattr(hr, "_load_commander_name_lookup", lambda: set())
|
||||
|
||||
exclusion_entry = {
|
||||
"name": "Elbrus, the Binding Blade // Withengar Unbound",
|
||||
"primary_face": "Elbrus, the Binding Blade",
|
||||
"eligible_faces": ["Withengar Unbound"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(hr, "lookup_commander_detail", lambda name: exclusion_entry if "Withengar" in name else None)
|
||||
|
||||
with pytest.raises(CommanderValidationError) as excinfo:
|
||||
hr._validate_commander_available("Withengar Unbound")
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "secondary face" in message.lower()
|
||||
assert "Withengar" in message
|
||||
|
||||
|
||||
def test_commander_theme_tags_enriched(tmp_csv_dir):
|
||||
import importlib
|
||||
|
||||
setup_module = importlib.import_module("file_setup.setup")
|
||||
|
||||
name = "Eddie Brock // Venom, Lethal Protector"
|
||||
front_face = "Venom, Eddie Brock"
|
||||
back_face = "Venom, Lethal Protector"
|
||||
|
||||
cards_df = pd.DataFrame(
|
||||
[
|
||||
_make_card_row(
|
||||
name=name,
|
||||
face_name=front_face,
|
||||
type_line="Legendary Creature — Symbiote",
|
||||
side="a",
|
||||
layout="modal_dfc",
|
||||
power="3",
|
||||
toughness="3",
|
||||
text="Other creatures you control get +1/+1.",
|
||||
),
|
||||
_make_card_row(
|
||||
name=name,
|
||||
face_name=back_face,
|
||||
type_line="Legendary Creature — Horror",
|
||||
side="b",
|
||||
layout="modal_dfc",
|
||||
power="5",
|
||||
toughness="5",
|
||||
text="Menace",
|
||||
),
|
||||
]
|
||||
)
|
||||
cards_df.to_csv(tmp_csv_dir / "cards.csv", index=False)
|
||||
|
||||
color_df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"name": name,
|
||||
"faceName": front_face,
|
||||
"themeTags": "['Aggro', 'Counters']",
|
||||
"creatureTypes": "['Human', 'Warrior']",
|
||||
"roleTags": "['Commander']",
|
||||
},
|
||||
{
|
||||
"name": name,
|
||||
"faceName": back_face,
|
||||
"themeTags": "['Graveyard']",
|
||||
"creatureTypes": "['Demon']",
|
||||
"roleTags": "['Finisher']",
|
||||
},
|
||||
]
|
||||
)
|
||||
color_df.to_csv(tmp_csv_dir / "black_cards.csv", index=False)
|
||||
|
||||
setup_module.determine_commanders()
|
||||
|
||||
commander_path = tmp_csv_dir / "commander_cards.csv"
|
||||
assert commander_path.exists(), "Expected commander CSV to be generated"
|
||||
|
||||
commander_df = pd.read_csv(
|
||||
commander_path,
|
||||
converters={
|
||||
"themeTags": ast.literal_eval,
|
||||
"creatureTypes": ast.literal_eval,
|
||||
"roleTags": ast.literal_eval,
|
||||
},
|
||||
)
|
||||
assert "themeTags" in commander_df.columns
|
||||
|
||||
row = commander_df[commander_df["faceName"] == front_face].iloc[0]
|
||||
assert set(row["themeTags"]) == {"Aggro", "Counters", "Graveyard"}
|
||||
assert set(row["creatureTypes"]) == {"Human", "Warrior", "Demon"}
|
||||
assert set(row["roleTags"]) == {"Commander", "Finisher"}
|
||||
80
code/tests/test_export_mdfc_annotations.py
Normal file
80
code/tests/test_export_mdfc_annotations.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from code.deck_builder.phases.phase6_reporting import ReportingMixin
|
||||
|
||||
|
||||
class DummyBuilder(ReportingMixin):
|
||||
def __init__(self) -> None:
|
||||
self.card_library = {
|
||||
"Valakut Awakening // Valakut Stoneforge": {
|
||||
"Card Type": "Instant",
|
||||
"Count": 2,
|
||||
"Mana Cost": "{2}{R}",
|
||||
"Mana Value": "3",
|
||||
"Role": "",
|
||||
"Tags": [],
|
||||
},
|
||||
"Mountain": {
|
||||
"Card Type": "Land",
|
||||
"Count": 1,
|
||||
"Mana Cost": "",
|
||||
"Mana Value": "0",
|
||||
"Role": "",
|
||||
"Tags": [],
|
||||
},
|
||||
}
|
||||
self.color_identity = ["R"]
|
||||
self.output_func = lambda *_args, **_kwargs: None # silence export logs
|
||||
self._full_cards_df = None
|
||||
self._combined_cards_df = None
|
||||
self.custom_export_base = "test_dfc_export"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def builder(monkeypatch: pytest.MonkeyPatch) -> DummyBuilder:
|
||||
matrix = {
|
||||
"Valakut Awakening // Valakut Stoneforge": {
|
||||
"R": 1,
|
||||
"_dfc_land": True,
|
||||
"_dfc_counts_as_extra": True,
|
||||
},
|
||||
"Mountain": {"R": 1},
|
||||
}
|
||||
|
||||
def _fake_compute(card_library, *_args, **_kwargs):
|
||||
return matrix
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deck_builder.builder_utils.compute_color_source_matrix",
|
||||
_fake_compute,
|
||||
)
|
||||
return DummyBuilder()
|
||||
|
||||
|
||||
def test_export_decklist_csv_includes_dfc_note(tmp_path: Path, builder: DummyBuilder) -> None:
|
||||
csv_path = Path(builder.export_decklist_csv(directory=str(tmp_path)))
|
||||
with csv_path.open("r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
rows = {row["Name"]: row for row in reader}
|
||||
|
||||
valakut_row = rows["Valakut Awakening // Valakut Stoneforge"]
|
||||
assert valakut_row["DFCNote"] == "MDFC: Adds extra land slot"
|
||||
|
||||
mountain_row = rows["Mountain"]
|
||||
assert mountain_row["DFCNote"] == ""
|
||||
|
||||
|
||||
def test_export_decklist_text_appends_dfc_annotation(tmp_path: Path, builder: DummyBuilder) -> None:
|
||||
text_path = Path(builder.export_decklist_text(directory=str(tmp_path)))
|
||||
lines = text_path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
valakut_line = next(line for line in lines if line.startswith("2 Valakut Awakening"))
|
||||
assert "[MDFC: Adds extra land slot]" in valakut_line
|
||||
|
||||
mountain_line = next(line for line in lines if line.strip().endswith("Mountain"))
|
||||
assert "MDFC" not in mountain_line
|
||||
150
code/tests/test_land_summary_totals.py
Normal file
150
code/tests/test_land_summary_totals.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Any, List
|
||||
|
||||
import pytest
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from code.deck_builder.phases.phase6_reporting import ReportingMixin
|
||||
from code.deck_builder.summary_telemetry import get_mdfc_metrics, _reset_metrics_for_test
|
||||
|
||||
|
||||
class DummyBuilder(ReportingMixin):
|
||||
def __init__(self, card_library: Dict[str, Dict[str, Any]], colors: List[str]):
|
||||
self.card_library = card_library
|
||||
self.color_identity = colors
|
||||
self.output_lines: List[str] = []
|
||||
self.output_func = self.output_lines.append # type: ignore[assignment]
|
||||
self._full_cards_df = None
|
||||
self._combined_cards_df = None
|
||||
self.include_exclude_diagnostics = None
|
||||
self.include_cards = []
|
||||
self.exclude_cards = []
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_card_library() -> Dict[str, Dict[str, Any]]:
|
||||
return {
|
||||
"Mountain": {"Card Type": "Land", "Count": 35, "Mana Cost": "", "Role": "", "Tags": []},
|
||||
"Branchloft Pathway // Boulderloft Pathway": {
|
||||
"Card Type": "Land",
|
||||
"Count": 1,
|
||||
"Mana Cost": "",
|
||||
"Role": "",
|
||||
"Tags": [],
|
||||
},
|
||||
"Valakut Awakening // Valakut Stoneforge": {
|
||||
"Card Type": "Instant",
|
||||
"Count": 2,
|
||||
"Mana Cost": "{2}{R}",
|
||||
"Role": "",
|
||||
"Tags": [],
|
||||
},
|
||||
"Cultivate": {"Card Type": "Sorcery", "Count": 1, "Mana Cost": "{2}{G}", "Role": "", "Tags": []},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_matrix(monkeypatch):
|
||||
matrix = {
|
||||
"Mountain": {"R": 1},
|
||||
"Branchloft Pathway // Boulderloft Pathway": {"G": 1, "W": 1, "_dfc_land": True},
|
||||
"Valakut Awakening // Valakut Stoneforge": {
|
||||
"R": 1,
|
||||
"_dfc_land": True,
|
||||
"_dfc_counts_as_extra": True,
|
||||
},
|
||||
"Cultivate": {},
|
||||
}
|
||||
|
||||
def _fake_compute(card_library, *_):
|
||||
return matrix
|
||||
|
||||
monkeypatch.setattr("deck_builder.builder_utils.compute_color_source_matrix", _fake_compute)
|
||||
return matrix
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_mdfc_metrics():
|
||||
_reset_metrics_for_test()
|
||||
yield
|
||||
_reset_metrics_for_test()
|
||||
|
||||
|
||||
def test_build_deck_summary_includes_mdfc_totals(sample_card_library, fake_matrix):
|
||||
builder = DummyBuilder(sample_card_library, ["R", "G"])
|
||||
summary = builder.build_deck_summary()
|
||||
|
||||
land_summary = summary.get("land_summary")
|
||||
assert land_summary["traditional"] == 36
|
||||
assert land_summary["dfc_lands"] == 2
|
||||
assert land_summary["with_dfc"] == 38
|
||||
assert land_summary["headline"] == "Lands: 36 (38 with DFC)"
|
||||
|
||||
dfc_cards = {card["name"]: card for card in land_summary["dfc_cards"]}
|
||||
branch = dfc_cards["Branchloft Pathway // Boulderloft Pathway"]
|
||||
assert branch["count"] == 1
|
||||
assert set(branch["colors"]) == {"G", "W"}
|
||||
assert branch["adds_extra_land"] is False
|
||||
assert branch["counts_as_land"] is True
|
||||
assert branch["note"] == "Counts as land slot"
|
||||
assert "faces" in branch
|
||||
assert isinstance(branch["faces"], list) and branch["faces"]
|
||||
assert all("mana_cost" in face for face in branch["faces"])
|
||||
|
||||
valakut = dfc_cards["Valakut Awakening // Valakut Stoneforge"]
|
||||
assert valakut["count"] == 2
|
||||
assert valakut["colors"] == ["R"]
|
||||
assert valakut["adds_extra_land"] is True
|
||||
assert valakut["counts_as_land"] is False
|
||||
assert valakut["note"] == "Adds extra land slot"
|
||||
assert any(face.get("produces_mana") for face in valakut.get("faces", []))
|
||||
|
||||
mana_cards = summary["mana_generation"]["cards"]
|
||||
red_sources = {item["name"]: item for item in mana_cards["R"]}
|
||||
assert red_sources["Valakut Awakening // Valakut Stoneforge"]["dfc"] is True
|
||||
assert red_sources["Mountain"]["dfc"] is False
|
||||
|
||||
|
||||
def test_cli_summary_mentions_mdfc_totals(sample_card_library, fake_matrix):
|
||||
builder = DummyBuilder(sample_card_library, ["R", "G"])
|
||||
builder.print_type_summary()
|
||||
joined = "\n".join(builder.output_lines)
|
||||
assert "Lands: 36 (38 with DFC)" in joined
|
||||
assert "MDFC sources:" in joined
|
||||
|
||||
|
||||
def test_deck_summary_template_renders_land_copy(sample_card_library, fake_matrix):
|
||||
builder = DummyBuilder(sample_card_library, ["R", "G"])
|
||||
summary = builder.build_deck_summary()
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader("code/web/templates"),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
template = env.get_template("partials/deck_summary.html")
|
||||
html = template.render(
|
||||
summary=summary,
|
||||
synergies=[],
|
||||
game_changers=[],
|
||||
owned_set=set(),
|
||||
combos=[],
|
||||
commander=None,
|
||||
)
|
||||
|
||||
assert "Lands: 36 (38 with DFC)" in html
|
||||
assert "DFC land" in html
|
||||
|
||||
|
||||
def test_deck_summary_records_mdfc_telemetry(sample_card_library, fake_matrix):
|
||||
builder = DummyBuilder(sample_card_library, ["R", "G"])
|
||||
builder.build_deck_summary()
|
||||
|
||||
metrics = get_mdfc_metrics()
|
||||
assert metrics["total_builds"] == 1
|
||||
assert metrics["builds_with_mdfc"] == 1
|
||||
assert metrics["total_mdfc_lands"] == 2
|
||||
assert metrics["last_summary"]["dfc_lands"] == 2
|
||||
top_cards = metrics.get("top_cards") or {}
|
||||
assert top_cards.get("Valakut Awakening // Valakut Stoneforge") == 2
|
||||
assert top_cards.get("Branchloft Pathway // Boulderloft Pathway") == 1
|
||||
45
code/tests/test_mdfc_basic_swap.py
Normal file
45
code/tests/test_mdfc_basic_swap.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import MethodType
|
||||
|
||||
from deck_builder.builder import DeckBuilder
|
||||
|
||||
|
||||
def _builder_with_forest() -> DeckBuilder:
|
||||
builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
|
||||
builder.card_library = {
|
||||
"Forest": {"Card Name": "Forest", "Card Type": "Land", "Count": 5},
|
||||
}
|
||||
return builder
|
||||
|
||||
|
||||
def _stub_modal_matrix(builder: DeckBuilder) -> None:
|
||||
def fake_matrix(self: DeckBuilder):
|
||||
return {
|
||||
"Bala Ged Recovery": {"G": 1, "_dfc_counts_as_extra": True},
|
||||
"Forest": {"G": 1},
|
||||
}
|
||||
|
||||
builder._compute_color_source_matrix = MethodType(fake_matrix, builder) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_modal_dfc_swaps_basic_when_enabled():
|
||||
builder = _builder_with_forest()
|
||||
builder.swap_mdfc_basics = True
|
||||
_stub_modal_matrix(builder)
|
||||
|
||||
builder.add_card("Bala Ged Recovery", card_type="Instant")
|
||||
|
||||
assert builder.card_library["Forest"]["Count"] == 4
|
||||
assert "Bala Ged Recovery" in builder.card_library
|
||||
|
||||
|
||||
def test_modal_dfc_does_not_swap_when_disabled():
|
||||
builder = _builder_with_forest()
|
||||
builder.swap_mdfc_basics = False
|
||||
_stub_modal_matrix(builder)
|
||||
|
||||
builder.add_card("Bala Ged Recovery", card_type="Instant")
|
||||
|
||||
assert builder.card_library["Forest"]["Count"] == 5
|
||||
assert "Bala Ged Recovery" in builder.card_library
|
||||
192
code/tests/test_multi_face_merge.py
Normal file
192
code/tests/test_multi_face_merge.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from code.tagging.multi_face_merger import merge_multi_face_rows
|
||||
|
||||
|
||||
def _build_dataframe() -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"name": "Eddie Brock // Venom, Lethal Protector",
|
||||
"faceName": "Eddie Brock",
|
||||
"edhrecRank": 12345.0,
|
||||
"colorIdentity": "B",
|
||||
"colors": "B",
|
||||
"manaCost": "{3}{B}{B}",
|
||||
"manaValue": 5.0,
|
||||
"type": "Legendary Creature — Human",
|
||||
"creatureTypes": ["Human"],
|
||||
"text": "When Eddie Brock enters...",
|
||||
"power": 3,
|
||||
"toughness": 4,
|
||||
"keywords": "Transform",
|
||||
"themeTags": ["Aggro", "Control"],
|
||||
"layout": "transform",
|
||||
"side": "a",
|
||||
"roleTags": ["Value Engine"],
|
||||
},
|
||||
{
|
||||
"name": "Eddie Brock // Venom, Lethal Protector",
|
||||
"faceName": "Venom, Lethal Protector",
|
||||
"edhrecRank": 12345.0,
|
||||
"colorIdentity": "B",
|
||||
"colors": "B",
|
||||
"manaCost": "",
|
||||
"manaValue": 5.0,
|
||||
"type": "Legendary Creature — Symbiote",
|
||||
"creatureTypes": ["Symbiote"],
|
||||
"text": "Whenever Venom attacks...",
|
||||
"power": 5,
|
||||
"toughness": 5,
|
||||
"keywords": "Menace, Transform",
|
||||
"themeTags": ["Menace", "Legends Matter"],
|
||||
"layout": "transform",
|
||||
"side": "b",
|
||||
"roleTags": ["Finisher"],
|
||||
},
|
||||
{
|
||||
"name": "Bonecrusher Giant // Stomp",
|
||||
"faceName": "Bonecrusher Giant",
|
||||
"edhrecRank": 6789.0,
|
||||
"colorIdentity": "R",
|
||||
"colors": "R",
|
||||
"manaCost": "{2}{R}",
|
||||
"manaValue": 3.0,
|
||||
"type": "Creature — Giant",
|
||||
"creatureTypes": ["Giant"],
|
||||
"text": "Whenever this creature becomes the target...",
|
||||
"power": 4,
|
||||
"toughness": 3,
|
||||
"keywords": "",
|
||||
"themeTags": ["Aggro"],
|
||||
"layout": "adventure",
|
||||
"side": "a",
|
||||
"roleTags": [],
|
||||
},
|
||||
{
|
||||
"name": "Bonecrusher Giant // Stomp",
|
||||
"faceName": "Stomp",
|
||||
"edhrecRank": 6789.0,
|
||||
"colorIdentity": "R",
|
||||
"colors": "R",
|
||||
"manaCost": "{1}{R}",
|
||||
"manaValue": 2.0,
|
||||
"type": "Instant — Adventure",
|
||||
"creatureTypes": [],
|
||||
"text": "Stomp deals 2 damage to any target.",
|
||||
"power": None,
|
||||
"toughness": None,
|
||||
"keywords": "Instant",
|
||||
"themeTags": ["Removal"],
|
||||
"layout": "adventure",
|
||||
"side": "b",
|
||||
"roleTags": [],
|
||||
},
|
||||
{
|
||||
"name": "Expansion // Explosion",
|
||||
"faceName": "Expansion",
|
||||
"edhrecRank": 4321.0,
|
||||
"colorIdentity": "U, R",
|
||||
"colors": "U, R",
|
||||
"manaCost": "{U/R}{U/R}",
|
||||
"manaValue": 2.0,
|
||||
"type": "Instant",
|
||||
"creatureTypes": [],
|
||||
"text": "Copy target instant or sorcery spell...",
|
||||
"power": None,
|
||||
"toughness": None,
|
||||
"keywords": "",
|
||||
"themeTags": ["Spell Copy"],
|
||||
"layout": "split",
|
||||
"side": "a",
|
||||
"roleTags": ["Copy Enabler"],
|
||||
},
|
||||
{
|
||||
"name": "Expansion // Explosion",
|
||||
"faceName": "Explosion",
|
||||
"edhrecRank": 4321.0,
|
||||
"colorIdentity": "U, R",
|
||||
"colors": "U, R",
|
||||
"manaCost": "{X}{X}{U}{R}",
|
||||
"manaValue": 4.0,
|
||||
"type": "Instant",
|
||||
"creatureTypes": [],
|
||||
"text": "Explosion deals X damage to any target...",
|
||||
"power": None,
|
||||
"toughness": None,
|
||||
"keywords": "",
|
||||
"themeTags": ["Burn", "Card Draw"],
|
||||
"layout": "split",
|
||||
"side": "b",
|
||||
"roleTags": ["Finisher"],
|
||||
},
|
||||
{
|
||||
"name": "Persistent Petitioners",
|
||||
"faceName": "Persistent Petitioners",
|
||||
"edhrecRank": 5555.0,
|
||||
"colorIdentity": "U",
|
||||
"colors": "U",
|
||||
"manaCost": "{1}{U}",
|
||||
"manaValue": 2.0,
|
||||
"type": "Creature — Human Advisor",
|
||||
"creatureTypes": ["Human", "Advisor"],
|
||||
"text": "{1}{U}, Tap four untapped Advisors you control: Mill 12.",
|
||||
"power": 1,
|
||||
"toughness": 3,
|
||||
"keywords": "",
|
||||
"themeTags": ["Mill"],
|
||||
"layout": "normal",
|
||||
"side": "",
|
||||
"roleTags": ["Mill Enabler"],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_merge_multi_face_rows_combines_themes_and_keywords():
|
||||
df = _build_dataframe()
|
||||
|
||||
merged = merge_multi_face_rows(df, "grixis", logger=None)
|
||||
|
||||
# Eddie Brock merge assertions
|
||||
eddie = merged[merged["name"] == "Eddie Brock // Venom, Lethal Protector"].iloc[0]
|
||||
assert set(eddie["themeTags"]) == {
|
||||
"Aggro",
|
||||
"Control",
|
||||
"Legends Matter",
|
||||
"Menace",
|
||||
}
|
||||
assert set(eddie["creatureTypes"]) == {"Human", "Symbiote"}
|
||||
assert eddie["keywords"] == "Menace, Transform"
|
||||
|
||||
assert (merged["faceName"] == "Venom, Lethal Protector").sum() == 0
|
||||
|
||||
# Bonecrusher Giant adventure merge assertions
|
||||
bonecrusher = merged[merged["name"] == "Bonecrusher Giant // Stomp"].iloc[0]
|
||||
assert set(bonecrusher["themeTags"]) == {"Aggro", "Removal"}
|
||||
assert set(bonecrusher["creatureTypes"]) == {"Giant"}
|
||||
assert bonecrusher["keywords"] == "Instant"
|
||||
assert (merged["faceName"] == "Stomp").sum() == 0
|
||||
|
||||
# Split card merge assertions
|
||||
explosion = merged[merged["name"] == "Expansion // Explosion"].iloc[0]
|
||||
assert set(explosion["themeTags"]) == {"Spell Copy", "Burn", "Card Draw"}
|
||||
assert set(explosion["roleTags"]) == {"Copy Enabler", "Finisher"}
|
||||
assert (merged["faceName"] == "Explosion").sum() == 0
|
||||
|
||||
# Persistent Petitioners should remain untouched
|
||||
petitioners = merged[merged["name"] == "Persistent Petitioners"].iloc[0]
|
||||
assert petitioners["themeTags"] == ["Mill"]
|
||||
assert petitioners["roleTags"] == ["Mill Enabler"]
|
||||
assert "faceDetails" not in merged.columns
|
||||
assert len(merged) == 4
|
||||
|
||||
|
||||
def test_merge_multi_face_rows_is_idempotent():
|
||||
df = _build_dataframe()
|
||||
once = merge_multi_face_rows(df, "izzet", logger=None)
|
||||
twice = merge_multi_face_rows(once, "izzet", logger=None)
|
||||
|
||||
pd.testing.assert_frame_equal(once, twice)
|
||||
40
code/tests/test_setup_filters.py
Normal file
40
code/tests/test_setup_filters.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import pandas as pd
|
||||
|
||||
from file_setup.setup_utils import filter_dataframe
|
||||
|
||||
|
||||
def _record(name: str, security_stamp: str) -> dict[str, object]:
|
||||
return {
|
||||
"name": name,
|
||||
"faceName": name,
|
||||
"edhrecRank": 100,
|
||||
"colorIdentity": "G",
|
||||
"colors": "G",
|
||||
"manaCost": "{G}",
|
||||
"manaValue": 1,
|
||||
"type": "Creature",
|
||||
"layout": "normal",
|
||||
"text": "",
|
||||
"power": "1",
|
||||
"toughness": "1",
|
||||
"keywords": "",
|
||||
"side": "a",
|
||||
"availability": "paper,arena",
|
||||
"promoTypes": "",
|
||||
"securityStamp": security_stamp,
|
||||
"printings": "RNA",
|
||||
}
|
||||
|
||||
|
||||
def test_filter_dataframe_removes_acorn_and_heart_security_stamps() -> None:
|
||||
df = pd.DataFrame(
|
||||
[
|
||||
_record("Acorn Card", "Acorn"),
|
||||
_record("Heart Card", "heart"),
|
||||
_record("Legal Card", ""),
|
||||
]
|
||||
)
|
||||
|
||||
filtered = filter_dataframe(df, banned_cards=[])
|
||||
|
||||
assert list(filtered["name"]) == ["Legal Card"]
|
||||
|
|
@ -15,6 +15,9 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
|||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from typing import Any, Optional, Dict, Iterable, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from code.deck_builder.summary_telemetry import get_mdfc_metrics
|
||||
from tagging.multi_face_merger import load_merge_summary
|
||||
from .services.combo_utils import detect_all as _detect_all
|
||||
from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
|
||||
from .services.tasks import get_session, new_sid, set_session_value # type: ignore
|
||||
|
|
@ -873,6 +876,17 @@ async def status_random_theme_stats():
|
|||
return JSONResponse({"ok": False, "error": "internal_error"}, status_code=500)
|
||||
|
||||
|
||||
@app.get("/status/dfc_metrics")
|
||||
async def status_dfc_metrics():
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
try:
|
||||
return JSONResponse({"ok": True, "metrics": get_mdfc_metrics()})
|
||||
except Exception as exc: # pragma: no cover - defensive log
|
||||
logging.getLogger("web").warning("Failed to fetch MDFC metrics: %s", exc, exc_info=True)
|
||||
return JSONResponse({"ok": False, "error": "internal_error"}, status_code=500)
|
||||
|
||||
|
||||
def random_modes_enabled() -> bool:
|
||||
"""Dynamic check so tests that set env after import still work.
|
||||
|
||||
|
|
@ -2352,7 +2366,13 @@ async def trigger_error(kind: str = Query("http")):
|
|||
async def diagnostics_home(request: Request) -> HTMLResponse:
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
return templates.TemplateResponse("diagnostics/index.html", {"request": request})
|
||||
return templates.TemplateResponse(
|
||||
"diagnostics/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"merge_summary": load_merge_summary(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/diagnostics/perf", response_class=HTMLResponse)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from path_util import csv_dir as _csv_dir
|
|||
from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
|
||||
from ..services.telemetry import log_commander_create_deck
|
||||
from urllib.parse import urlparse
|
||||
from commander_exclusions import lookup_commander_detail
|
||||
|
||||
# Cache for available card names used by validation endpoints
|
||||
_AVAILABLE_CARDS_CACHE: set[str] | None = None
|
||||
|
|
@ -150,6 +151,7 @@ def _rebuild_ctx_with_multicopy(sess: dict) -> None:
|
|||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
swap_mdfc_basics=bool(sess.get("swap_mdfc_basics")),
|
||||
)
|
||||
except Exception:
|
||||
# If rebuild fails (e.g., commander not found in test), fall back to injecting
|
||||
|
|
@ -415,12 +417,22 @@ async def multicopy_save(
|
|||
async def build_new_modal(request: Request) -> HTMLResponse:
|
||||
"""Return the New Deck modal content (for an overlay)."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
ctx = {
|
||||
"request": request,
|
||||
"brackets": orch.bracket_options(),
|
||||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"form": {
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_count": sess.get("combo_target_count"),
|
||||
"combo_balance": sess.get("combo_balance"),
|
||||
"enable_multicopy": bool(sess.get("multi_copy")),
|
||||
"use_owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
}
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -432,7 +444,38 @@ async def build_new_candidates(request: Request, commander: str = Query("")) ->
|
|||
"""Return a small list of commander candidates for the modal live search."""
|
||||
q = (commander or "").strip()
|
||||
items = orch.commander_candidates(q, limit=8) if q else []
|
||||
ctx = {"request": request, "query": q, "candidates": items}
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for name, score, colors in items:
|
||||
detail = lookup_commander_detail(name)
|
||||
preferred = name
|
||||
warning = None
|
||||
if detail:
|
||||
eligible_raw = detail.get("eligible_faces")
|
||||
eligible = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else []
|
||||
norm_name = str(name).strip().casefold()
|
||||
eligible_norms = [face.casefold() for face in eligible]
|
||||
if eligible and norm_name not in eligible_norms:
|
||||
preferred = eligible[0]
|
||||
primary = str(detail.get("primary_face") or detail.get("name") or name).strip()
|
||||
if len(eligible) == 1:
|
||||
warning = (
|
||||
f"Use the back face '{preferred}' when building. Front face '{primary}' can't lead a deck."
|
||||
)
|
||||
else:
|
||||
faces = ", ".join(f"'{face}'" for face in eligible)
|
||||
warning = (
|
||||
f"This commander only works from specific faces: {faces}."
|
||||
)
|
||||
candidates.append(
|
||||
{
|
||||
"display": name,
|
||||
"value": preferred,
|
||||
"score": score,
|
||||
"colors": colors,
|
||||
"warning": warning,
|
||||
}
|
||||
)
|
||||
ctx = {"request": request, "query": q, "candidates": candidates}
|
||||
return templates.TemplateResponse("build/_new_deck_candidates.html", ctx)
|
||||
|
||||
|
||||
|
|
@ -445,6 +488,7 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
|
|||
tags = orch.tags_for_commander(info["name"]) or []
|
||||
recommended = orch.recommended_tags_for_commander(info["name"]) if tags else []
|
||||
recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {}
|
||||
exclusion_detail = lookup_commander_detail(info["name"])
|
||||
# Render tags slot content and OOB commander preview simultaneously
|
||||
# Game Changer flag for this commander (affects bracket UI in modal via tags partial consumer)
|
||||
is_gc = False
|
||||
|
|
@ -454,7 +498,7 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
|
|||
is_gc = False
|
||||
ctx = {
|
||||
"request": request,
|
||||
"commander": {"name": info["name"]},
|
||||
"commander": {"name": info["name"], "exclusion": exclusion_detail},
|
||||
"tags": tags,
|
||||
"recommended": recommended,
|
||||
"recommended_reasons": recommended_reasons,
|
||||
|
|
@ -553,6 +597,9 @@ async def build_new_submit(
|
|||
combo_count: int | None = Form(None),
|
||||
combo_balance: str | None = Form(None),
|
||||
enable_multicopy: bool = Form(False),
|
||||
use_owned_only: bool = Form(False),
|
||||
prefer_owned: bool = Form(False),
|
||||
swap_mdfc_basics: bool = Form(False),
|
||||
# Integrated Multi-Copy (optional)
|
||||
multi_choice_id: str | None = Form(None),
|
||||
multi_count: int | None = Form(None),
|
||||
|
|
@ -567,6 +614,57 @@ async def build_new_submit(
|
|||
"""Handle New Deck modal submit and immediately start the build (skip separate review page)."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
|
||||
def _form_state(commander_value: str) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"commander": commander_value,
|
||||
"primary_tag": primary_tag or "",
|
||||
"secondary_tag": secondary_tag or "",
|
||||
"tertiary_tag": tertiary_tag or "",
|
||||
"tag_mode": tag_mode or "AND",
|
||||
"bracket": bracket,
|
||||
"combo_count": combo_count,
|
||||
"combo_balance": (combo_balance or "mix"),
|
||||
"prefer_combos": bool(prefer_combos),
|
||||
"enable_multicopy": bool(enable_multicopy),
|
||||
"use_owned_only": bool(use_owned_only),
|
||||
"prefer_owned": bool(prefer_owned),
|
||||
"swap_mdfc_basics": bool(swap_mdfc_basics),
|
||||
"include_cards": include_cards or "",
|
||||
"exclude_cards": exclude_cards or "",
|
||||
"enforcement_mode": enforcement_mode or "warn",
|
||||
"allow_illegal": bool(allow_illegal),
|
||||
"fuzzy_matching": bool(fuzzy_matching),
|
||||
}
|
||||
|
||||
commander_detail = lookup_commander_detail(commander)
|
||||
if commander_detail:
|
||||
eligible_raw = commander_detail.get("eligible_faces")
|
||||
eligible_faces = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else []
|
||||
if eligible_faces:
|
||||
norm_input = str(commander).strip().casefold()
|
||||
eligible_norms = [face.casefold() for face in eligible_faces]
|
||||
if norm_input not in eligible_norms:
|
||||
suggested = eligible_faces[0]
|
||||
primary_face = str(commander_detail.get("primary_face") or commander_detail.get("name") or commander).strip()
|
||||
faces_str = ", ".join(f"'{face}'" for face in eligible_faces)
|
||||
error_msg = (
|
||||
f"'{primary_face or commander}' can't lead a deck. Use {faces_str} as the commander instead. "
|
||||
"We've updated the commander field for you."
|
||||
)
|
||||
ctx = {
|
||||
"request": request,
|
||||
"error": error_msg,
|
||||
"brackets": orch.bracket_options(),
|
||||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"form": _form_state(suggested),
|
||||
}
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
# Normalize and validate commander selection (best-effort via orchestrator)
|
||||
sel = orch.commander_select(commander)
|
||||
if not sel.get("ok"):
|
||||
|
|
@ -578,23 +676,7 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"form": {
|
||||
"name": name,
|
||||
"commander": commander,
|
||||
"primary_tag": primary_tag or "",
|
||||
"secondary_tag": secondary_tag or "",
|
||||
"tertiary_tag": tertiary_tag or "",
|
||||
"tag_mode": tag_mode or "AND",
|
||||
"bracket": bracket,
|
||||
"combo_count": combo_count,
|
||||
"combo_balance": (combo_balance or "mix"),
|
||||
"prefer_combos": bool(prefer_combos),
|
||||
"include_cards": include_cards or "",
|
||||
"exclude_cards": exclude_cards or "",
|
||||
"enforcement_mode": enforcement_mode or "warn",
|
||||
"allow_illegal": bool(allow_illegal),
|
||||
"fuzzy_matching": bool(fuzzy_matching),
|
||||
}
|
||||
"form": _form_state(commander),
|
||||
}
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -654,6 +736,18 @@ async def build_new_submit(
|
|||
sess["prefer_combos"] = bool(prefer_combos)
|
||||
except Exception:
|
||||
sess["prefer_combos"] = False
|
||||
try:
|
||||
sess["use_owned_only"] = bool(use_owned_only)
|
||||
except Exception:
|
||||
sess["use_owned_only"] = False
|
||||
try:
|
||||
sess["prefer_owned"] = bool(prefer_owned)
|
||||
except Exception:
|
||||
sess["prefer_owned"] = False
|
||||
try:
|
||||
sess["swap_mdfc_basics"] = bool(swap_mdfc_basics)
|
||||
except Exception:
|
||||
sess["swap_mdfc_basics"] = False
|
||||
# Combos config from modal
|
||||
try:
|
||||
if combo_count is not None:
|
||||
|
|
@ -1267,6 +1361,9 @@ async def build_step3_submit(
|
|||
"labels": labels,
|
||||
"values": submitted,
|
||||
"commander": sess.get("commander"),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -1313,6 +1410,7 @@ async def build_step4_get(request: Request) -> HTMLResponse:
|
|||
"commander": commander,
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -1485,6 +1583,7 @@ async def build_toggle_owned_review(
|
|||
request: Request,
|
||||
use_owned_only: str | None = Form(None),
|
||||
prefer_owned: str | None = Form(None),
|
||||
swap_mdfc_basics: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
|
|
@ -1492,8 +1591,10 @@ async def build_toggle_owned_review(
|
|||
sess["last_step"] = 4
|
||||
only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False
|
||||
pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False
|
||||
swap_val = True if (swap_mdfc_basics and str(swap_mdfc_basics).strip() in ("1","true","on","yes")) else False
|
||||
sess["use_owned_only"] = only_val
|
||||
sess["prefer_owned"] = pref_val
|
||||
sess["swap_mdfc_basics"] = swap_val
|
||||
# Do not touch build_ctx here; user hasn't started the build yet from review
|
||||
labels = orch.ideal_labels()
|
||||
values = sess.get("ideals") or orch.ideal_defaults()
|
||||
|
|
@ -1507,6 +1608,7 @@ async def build_toggle_owned_review(
|
|||
"commander": commander,
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -2888,6 +2990,7 @@ async def build_permalink(request: Request):
|
|||
"flags": {
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
"locks": list(sess.get("locks", [])),
|
||||
}
|
||||
|
|
@ -2974,6 +3077,7 @@ async def build_from(request: Request, state: str | None = None) -> HTMLResponse
|
|||
flags = data.get("flags") or {}
|
||||
sess["use_owned_only"] = bool(flags.get("owned_only"))
|
||||
sess["prefer_owned"] = bool(flags.get("prefer_owned"))
|
||||
sess["swap_mdfc_basics"] = bool(flags.get("swap_mdfc_basics"))
|
||||
sess["locks"] = list(data.get("locks", []))
|
||||
# Optional random build rehydration
|
||||
try:
|
||||
|
|
@ -3037,6 +3141,7 @@ async def build_from(request: Request, state: str | None = None) -> HTMLResponse
|
|||
"commander": sess.get("commander"),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
"locks_restored": locks_restored,
|
||||
})
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
|
|||
|
|
@ -528,3 +528,13 @@ async def commanders_index(
|
|||
except Exception:
|
||||
pass
|
||||
return templates.TemplateResponse(template_name, context)
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def commanders_index_alias(
|
||||
request: Request,
|
||||
q: str | None = Query(default=None, alias="q"),
|
||||
theme: str | None = Query(default=None, alias="theme"),
|
||||
color: str | None = Query(default=None, alias="color"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
) -> HTMLResponse:
|
||||
return await commanders_index(request, q=q, theme=theme, color=color, page=page)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
|
|||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
}
|
||||
if include_name:
|
||||
ctx["name"] = sess.get("custom_export_base")
|
||||
|
|
@ -85,6 +86,7 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
|
|||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
include_cards=sess.get("include_cards"),
|
||||
exclude_cards=sess.get("exclude_cards"),
|
||||
swap_mdfc_basics=bool(sess.get("swap_mdfc_basics")),
|
||||
)
|
||||
if set_on_session:
|
||||
sess["build_ctx"] = ctx
|
||||
|
|
|
|||
|
|
@ -1847,6 +1847,7 @@ def start_build_ctx(
|
|||
combo_balance: str | None = None,
|
||||
include_cards: List[str] | None = None,
|
||||
exclude_cards: List[str] | None = None,
|
||||
swap_mdfc_basics: bool | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
logs: List[str] = []
|
||||
|
||||
|
|
@ -1914,6 +1915,11 @@ def start_build_ctx(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
b.swap_mdfc_basics = bool(swap_mdfc_basics)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Data load
|
||||
b.determine_color_identity()
|
||||
b.setup_dataframes()
|
||||
|
|
@ -1980,6 +1986,7 @@ def start_build_ctx(
|
|||
"history": [], # list of {i, key, label, snapshot}
|
||||
"locks": {str(n).strip().lower() for n in (locks or []) if str(n).strip()},
|
||||
"custom_export_base": str(custom_export_base).strip() if isinstance(custom_export_base, str) and custom_export_base.strip() else None,
|
||||
"swap_mdfc_basics": bool(swap_mdfc_basics),
|
||||
}
|
||||
return ctx
|
||||
|
||||
|
|
|
|||
|
|
@ -662,7 +662,7 @@
|
|||
window.__dfcFlipCard = function(card){ if(!card) return; flip(card, card.querySelector('.dfc-toggle')); };
|
||||
window.__dfcGetFace = function(card){ if(!card) return 'front'; return card.getAttribute(FACE_ATTR) || 'front'; };
|
||||
function scan(){
|
||||
document.querySelectorAll('.card-sample, .commander-cell, .card-tile, .candidate-tile, .stack-card, .card-preview, .owned-row, .list-row').forEach(ensureButton);
|
||||
document.querySelectorAll('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .stack-card, .card-preview, .owned-row, .list-row').forEach(ensureButton);
|
||||
}
|
||||
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; }, { passive:true });
|
||||
document.addEventListener('DOMContentLoaded', scan);
|
||||
|
|
@ -1206,9 +1206,9 @@
|
|||
if(!el) return null;
|
||||
// If inside flip button
|
||||
var btn = el.closest && el.closest('.dfc-toggle');
|
||||
if(btn) return btn.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
if(btn) return btn.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
// Recognized container classes (add .stack-card for finished/random deck thumbnails)
|
||||
var container = el.closest && el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
if(container) return container;
|
||||
// Image-based detection (any card image carrying data-card-name)
|
||||
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))){
|
||||
|
|
@ -1264,12 +1264,12 @@
|
|||
window.hoverShowByName = function(name){
|
||||
try {
|
||||
var el = document.querySelector('[data-card-name="'+CSS.escape(name)+'"]');
|
||||
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
|
||||
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
|
||||
} catch(_) {}
|
||||
};
|
||||
// Keyboard accessibility & focus traversal (P2 UI Hover keyboard accessibility)
|
||||
document.addEventListener('focusin', function(e){ var card=e.target.closest && e.target.closest('.card-sample, .commander-cell'); if(card){ show(card, {clientX:card.getBoundingClientRect().left+10, clientY:card.getBoundingClientRect().top+10}); }});
|
||||
document.addEventListener('focusout', function(e){ var next=e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell'); if(!next) hide(); });
|
||||
document.addEventListener('focusin', function(e){ var card=e.target.closest && e.target.closest('.card-sample, .commander-cell, .commander-thumb'); if(card){ show(card, {clientX:card.getBoundingClientRect().left+10, clientY:card.getBoundingClientRect().top+10}); }});
|
||||
document.addEventListener('focusout', function(e){ var next=e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell, .commander-thumb'); if(!next) hide(); });
|
||||
document.addEventListener('keydown', function(e){ if(e.key==='Escape') hide(); });
|
||||
// Compact mode event listener
|
||||
document.addEventListener('mtg:hoverCompactToggle', function(){ panel.classList.toggle('compact-img', !!window.__hoverCompactMode); });
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
{% if candidates and candidates|length %}
|
||||
<ul style="list-style:none; padding:0; margin:.35rem 0; display:grid; gap:.25rem;" role="listbox" aria-label="Commander suggestions" tabindex="-1">
|
||||
{% for name, score, colors in candidates %}
|
||||
{% for cand in candidates %}
|
||||
<li>
|
||||
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ name|e }}"
|
||||
hx-get="/build/new/inspect?name={{ name|urlencode }}"
|
||||
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ cand.value|e }}" data-display="{{ cand.display|e }}"
|
||||
hx-get="/build/new/inspect?name={{ cand.display|urlencode }}"
|
||||
hx-target="#newdeck-tags-slot" hx-swap="innerHTML"
|
||||
hx-on="htmx:afterOnLoad: (function(){ try{ var n=this.getAttribute('data-name')||''; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=n; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=n; } }catch(_){ } }).call(this)">
|
||||
{{ name }}
|
||||
hx-on="htmx:afterOnLoad: (function(){ try{ var preferred=this.getAttribute('data-name')||''; var displayed=this.getAttribute('data-display')||preferred; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=preferred; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} try{ ci.dispatchEvent(new Event('input', { bubbles: true })); }catch(_){ } } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=displayed; } }catch(_){ } }).call(this)">
|
||||
{{ cand.display }}
|
||||
{% if cand.warning %}
|
||||
<span aria-hidden="true" style="margin-left:.35rem; font-size:11px; color:#facc15;">⚠</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% if cand.warning %}
|
||||
<div class="muted" style="font-size:11px; margin:.25rem 0 0 .5rem; color:#facc15;" role="note">⚠ {{ cand.warning }}</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -55,9 +55,9 @@
|
|||
<fieldset>
|
||||
<legend>Preferences</legend>
|
||||
<div style="text-align: left;">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 0.5rem; margin: 0;" title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
|
||||
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" style="margin: 0;" />
|
||||
<div style="margin-bottom: 1rem; display:flex; flex-direction:column; gap:0.75rem;">
|
||||
<label for="pref-combos-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
|
||||
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.prefer_combos %}checked{% endif %} />
|
||||
<span>Prioritize combos</span>
|
||||
</label>
|
||||
<div id="pref-combos-config" style="margin-top: 0.5rem; margin-left: 1.5rem; padding: 0.5rem; border: 1px solid var(--border); border-radius: 8px; display: none;">
|
||||
|
|
@ -80,12 +80,24 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 0.5rem; margin: 0;" title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
|
||||
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" style="margin: 0;" />
|
||||
<label for="pref-mc-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
|
||||
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.enable_multicopy %}checked{% endif %} />
|
||||
<span>Enable Multi-Copy package</span>
|
||||
</label>
|
||||
<div style="display:flex; flex-direction:column; gap:0.5rem; margin-top:0.75rem;">
|
||||
<label for="use-owned-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="Limit the pool to cards you already own. Cards outside your owned library will be skipped.">
|
||||
<input type="checkbox" name="use_owned_only" id="use-owned-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.use_owned_only %}checked{% endif %} />
|
||||
<span>Use only owned cards</span>
|
||||
</label>
|
||||
<label for="prefer-owned-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="Still allow unowned cards, but rank owned cards higher when choosing picks.">
|
||||
<input type="checkbox" name="prefer_owned" id="prefer-owned-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.prefer_owned %}checked{% endif %} />
|
||||
<span>Prefer owned cards (allow unowned fallback)</span>
|
||||
</label>
|
||||
<label for="swap-mdfc-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, modal DFC lands will replace a matching basic land as they are added so land counts stay level without manual trims.">
|
||||
<input type="checkbox" name="swap_mdfc_basics" id="swap-mdfc-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.swap_mdfc_basics %}checked{% endif %} />
|
||||
<span>Swap basics for MDFC lands</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,27 @@
|
|||
</script>
|
||||
</div>
|
||||
|
||||
{% set exclusion = commander.exclusion if commander is defined and commander.exclusion is defined else None %}
|
||||
{% if exclusion %}
|
||||
{% set eligible_raw = exclusion.eligible_faces if exclusion.eligible_faces is defined else [] %}
|
||||
{% set eligible_list = eligible_raw if eligible_raw is iterable else [] %}
|
||||
{% set eligible_lower = eligible_list | map('lower') | list %}
|
||||
{% set current_lower = commander.name|lower %}
|
||||
{% if eligible_list and (current_lower not in eligible_lower or exclusion.reason == 'secondary_face_only') %}
|
||||
<div class="muted" style="font-size:12px; margin-top:.35rem; color:#facc15;" role="note">
|
||||
{% if eligible_list|length == 1 %}
|
||||
⚠ This commander only works from '{{ eligible_list[0] }}'.
|
||||
{% if exclusion.primary_face and exclusion.primary_face|lower != eligible_list[0]|lower %}
|
||||
Front face '{{ exclusion.primary_face }}' can't lead a deck.
|
||||
{% endif %}
|
||||
We'll build using the supported face automatically.
|
||||
{% else %}
|
||||
⚠ This commander only works from these faces: {{ eligible_list | join(', ') }}. We'll build using the supported faces automatically.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
{% if tags and tags|length %}
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">Pick up to three themes. Toggle AND/OR to control how themes combine.</div>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@
|
|||
<input type="checkbox" name="prefer_owned" value="1" {% if prefer_owned %}checked{% endif %} onchange="this.form.requestSubmit();" />
|
||||
Prefer owned cards (allow unowned fallback)
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:.35rem;" title="When enabled, modal DFC lands will replace a matching basic land as they are added so land counts stay level without manual trims.">
|
||||
<input type="checkbox" name="swap_mdfc_basics" value="1" {% if swap_mdfc_basics %}checked{% endif %} onchange="this.form.requestSubmit();" />
|
||||
Swap basics for MDFC lands
|
||||
</label>
|
||||
<a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a>
|
||||
</form>
|
||||
<div class="muted" style="font-size:12px; margin-top:-.25rem;">Tip: Locked cards are respected on reruns in Step 5.</div>
|
||||
|
|
|
|||
|
|
@ -74,9 +74,10 @@
|
|||
<p>Tags: {{ deck_theme_tags|default([])|join(', ') }}</p>
|
||||
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
|
||||
<div style="display:flex;align-items:center;gap:1rem;">
|
||||
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
||||
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" style="background:#374151; color:#e5e7eb; border:none; border-radius:6px; padding:.25rem .5rem; cursor:pointer; font-size:12px;" title="Change owned settings in Review">Edit in Review</button>
|
||||
<div>Prefer-owned: <strong>{{ 'On' if prefer_owned else 'Off' }}</strong></div>
|
||||
<div>MDFC swap: <strong>{{ 'On' if swap_mdfc_basics else 'Off' }}</strong></div>
|
||||
</div>
|
||||
<span style="margin-left:auto;"><a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a></span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@
|
|||
.commander-list { display:flex; flex-direction:column; gap:1rem; margin-top:.5rem; }
|
||||
|
||||
.commander-row { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
|
||||
.commander-thumb { width:160px; flex:0 0 auto; }
|
||||
.commander-thumb { width:160px; flex:0 0 auto; position:relative; }
|
||||
.commander-thumb img { width:160px; height:auto; border-radius:10px; border:1px solid var(--border); background:#0b0d12; display:block; }
|
||||
.commander-main { flex:1 1 auto; display:flex; flex-direction:column; gap:.6rem; min-width:0; }
|
||||
.commander-header { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{# Commander row partial fed by CommanderView entries #}
|
||||
{% from "partials/_macros.html" import color_identity %}
|
||||
{% set record = entry.record %}
|
||||
{% set display_label = record.name if '//' in record.name else record.display_name %}
|
||||
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
|
||||
<div class="commander-thumb">
|
||||
{% set small = record.image_small_url or record.image_normal_url %}
|
||||
|
|
@ -12,12 +13,13 @@
|
|||
loading="lazy"
|
||||
decoding="async"
|
||||
data-card-name="{{ record.display_name }}"
|
||||
data-original-name="{{ record.name }}"
|
||||
data-hover-simple="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="commander-main">
|
||||
<div class="commander-header">
|
||||
<h3 class="commander-name">{{ record.display_name }}</h3>
|
||||
<h3 class="commander-name">{{ display_label }}</h3>
|
||||
{{ color_identity(record.color_identity, record.is_colorless, entry.color_aria_label, entry.color_label) }}
|
||||
</div>
|
||||
<p class="commander-context muted">{{ record.type_line or 'Legendary Creature' }}</p>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,62 @@
|
|||
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">Multi-face merge snapshot</h3>
|
||||
<div class="muted" style="margin-bottom:.35rem">Pulls from <code>logs/dfc_merge_summary.json</code> to verify merge coverage.</div>
|
||||
{% set colors = merge_summary.get('colors') if merge_summary else {} %}
|
||||
{% if colors %}
|
||||
<div class="muted" style="margin-bottom:.35rem">Last updated: {{ merge_summary.updated_at or 'unknown' }}</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%; border-collapse:collapse; font-size:13px;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--border); text-align:left;">
|
||||
<th style="padding:.35rem .25rem;">Color</th>
|
||||
<th style="padding:.35rem .25rem;">Groups merged</th>
|
||||
<th style="padding:.35rem .25rem;">Faces dropped</th>
|
||||
<th style="padding:.35rem .25rem;">Multi-face rows</th>
|
||||
<th style="padding:.35rem .25rem;">Latest entries</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for color, payload in colors.items()|dictsort %}
|
||||
<tr style="border-bottom:1px solid rgba(148,163,184,0.2);">
|
||||
<td style="padding:.35rem .25rem; font-weight:600;">{{ color|title }}</td>
|
||||
<td style="padding:.35rem .25rem;">{{ payload.group_count or 0 }}</td>
|
||||
<td style="padding:.35rem .25rem;">{{ payload.faces_dropped or 0 }}</td>
|
||||
<td style="padding:.35rem .25rem;">{{ payload.multi_face_rows or 0 }}</td>
|
||||
<td style="padding:.35rem .25rem;">
|
||||
{% set entries = payload.entries or [] %}
|
||||
{% if entries %}
|
||||
<details>
|
||||
<summary style="cursor:pointer;">{{ entries|length }} recorded</summary>
|
||||
<ul style="margin:.35rem 0 0 .75rem; padding:0; list-style:disc; max-height:180px; overflow:auto;">
|
||||
{% for entry in entries %}
|
||||
{% if loop.index0 < 5 %}
|
||||
<li style="margin-bottom:.25rem;">
|
||||
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
|
||||
</li>
|
||||
{% elif loop.index0 == 5 %}
|
||||
<li style="font-size:11px; opacity:.75;">… {{ entries|length - 5 }} more entries</li>
|
||||
{% break %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% else %}
|
||||
<span class="muted">No groups recorded</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted">No merge summary has been recorded. Run the tagger with multi-face merging enabled.</div>
|
||||
{% endif %}
|
||||
<div id="dfcMetrics" class="muted" style="margin-top:.5rem;">Loading MDFC metrics…</div>
|
||||
</div>
|
||||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">Performance (local)</h3>
|
||||
<div class="muted" style="margin-bottom:.35rem">Scroll the Step 5 list; this panel shows a rough FPS estimate and virtualization renders.</div>
|
||||
|
|
@ -193,6 +249,71 @@
|
|||
.catch(function(){ tokenEl.textContent = 'Theme stats unavailable'; });
|
||||
}
|
||||
loadTokenStats();
|
||||
var dfcMetricsEl = document.getElementById('dfcMetrics');
|
||||
function renderDfcMetrics(payload){
|
||||
if (!dfcMetricsEl) return;
|
||||
try {
|
||||
if (!payload || payload.ok !== true) {
|
||||
dfcMetricsEl.textContent = 'MDFC metrics unavailable';
|
||||
return;
|
||||
}
|
||||
var metrics = payload.metrics || {};
|
||||
var html = '';
|
||||
html += '<div><strong>Deck summaries observed:</strong> ' + String(metrics.total_builds || 0) + '</div>';
|
||||
var withDfc = Number(metrics.builds_with_mdfc || 0);
|
||||
var share = metrics.build_share != null ? Number(metrics.build_share) : null;
|
||||
if (!Number.isNaN(share) && share !== null) {
|
||||
share = (share * 100).toFixed(1);
|
||||
} else {
|
||||
share = null;
|
||||
}
|
||||
html += '<div><strong>With MDFCs:</strong> ' + String(withDfc);
|
||||
if (share !== null) {
|
||||
html += ' (' + share + '%)';
|
||||
}
|
||||
html += '</div>';
|
||||
var totalLands = Number(metrics.total_mdfc_lands || 0);
|
||||
var avg = metrics.avg_mdfc_lands != null ? Number(metrics.avg_mdfc_lands) : null;
|
||||
html += '<div><strong>Total MDFC lands:</strong> ' + String(totalLands);
|
||||
if (avg !== null && !Number.isNaN(avg)) {
|
||||
html += ' (avg ' + avg.toFixed(2) + ')';
|
||||
}
|
||||
html += '</div>';
|
||||
var top = metrics.top_cards || {};
|
||||
var topKeys = Object.keys(top);
|
||||
if (topKeys.length) {
|
||||
var items = topKeys.slice(0, 5).map(function(name){
|
||||
return name + ' (' + String(top[name]) + ')';
|
||||
});
|
||||
html += '<div style="font-size:11px;">Top MDFC sources: ' + items.join(', ') + '</div>';
|
||||
}
|
||||
var last = metrics.last_summary || {};
|
||||
if (typeof last.dfc_lands !== 'undefined') {
|
||||
html += '<div style="font-size:11px; margin-top:0.25rem;">Last summary: ' + String(last.dfc_lands || 0) + ' MDFC lands · total with MDFCs ' + String(last.with_dfc || 0) + '</div>';
|
||||
}
|
||||
if (metrics.last_updated) {
|
||||
html += '<div style="font-size:11px;">Updated: ' + String(metrics.last_updated) + '</div>';
|
||||
}
|
||||
dfcMetricsEl.innerHTML = html;
|
||||
} catch (_){
|
||||
dfcMetricsEl.textContent = 'MDFC metrics unavailable';
|
||||
}
|
||||
}
|
||||
function loadDfcMetrics(){
|
||||
if (!dfcMetricsEl) return;
|
||||
dfcMetricsEl.textContent = 'Loading MDFC metrics…';
|
||||
fetch('/status/dfc_metrics', { cache: 'no-store' })
|
||||
.then(function(resp){
|
||||
if (resp.status === 404) {
|
||||
dfcMetricsEl.textContent = 'Diagnostics disabled (metrics unavailable)';
|
||||
return null;
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.then(function(data){ if (data) renderDfcMetrics(data); })
|
||||
.catch(function(){ dfcMetricsEl.textContent = 'MDFC metrics unavailable'; });
|
||||
}
|
||||
loadDfcMetrics();
|
||||
// Theme status and reset
|
||||
try{
|
||||
var tEl = document.getElementById('themeSummary');
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@
|
|||
.stack-card:hover { z-index: 999; transform: translateY(-2px); box-shadow: 0 10px 22px rgba(0,0,0,.6); }
|
||||
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
|
||||
.owned-badge { position:absolute; top:6px; left:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center; pointer-events:none; z-index: 2; }
|
||||
.dfc-thumb-badge { position:absolute; bottom:8px; left:6px; background:rgba(15,23,42,.92); border:1px solid #34d399; color:#bbf7d0; border-radius:12px; font-size:11px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
|
||||
.dfc-thumb-badge.counts { border-color:#60a5fa; color:#bfdbfe; }
|
||||
.owned-flag { font-size:.95rem; opacity:.9; }
|
||||
</style>
|
||||
<div id="typeview-list" class="typeview">
|
||||
|
|
@ -47,8 +49,11 @@
|
|||
.list-row .count { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; text-align:right; color:#94a3b8; }
|
||||
.list-row .times { color:#94a3b8; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.list-row .flip-slot { min-width:2.4em; display:flex; justify-content:center; align-items:center; }
|
||||
.list-row .flip-slot { min-width:2.4em; display:flex; justify-content:flex-start; align-items:center; }
|
||||
.list-row .owned-flag { width: 1.6em; min-width: 1.6em; text-align:center; display:inline-block; }
|
||||
.dfc-land-chip { display:inline-flex; align-items:center; gap:.25rem; padding:2px 6px; border-radius:999px; font-size:11px; font-weight:600; background:#0f172a; border:1px solid #334155; color:#e5e7eb; line-height:1; }
|
||||
.dfc-land-chip.extra { border-color:#34d399; color:#a7f3d0; }
|
||||
.dfc-land-chip.counts { border-color:#60a5fa; color:#bfdbfe; }
|
||||
</style>
|
||||
<div class="list-grid">
|
||||
{% for c in clist %}
|
||||
|
|
@ -69,7 +74,11 @@
|
|||
<span class="count">{{ cnt }}</span>
|
||||
<span class="times">x</span>
|
||||
<span class="name dfc-anchor" title="{{ c.name }}" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}>{{ c.name }}</span>
|
||||
<span class="flip-slot" aria-hidden="true"></span>
|
||||
<span class="flip-slot" aria-hidden="true">
|
||||
{% if c.dfc_land %}
|
||||
<span class="dfc-land-chip {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC land{% if c.dfc_adds_extra_land %} +1{% endif %}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="owned-flag" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
@ -106,6 +115,9 @@
|
|||
sizes="(max-width: 1200px) 160px, 240px" />
|
||||
<div class="count-badge">{{ cnt }}x</div>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
{% if c.dfc_land %}
|
||||
<div class="dfc-thumb-badge {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC{% if c.dfc_adds_extra_land %}+1{% endif %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -122,6 +134,60 @@
|
|||
|
||||
<!-- Deck Summary initializer script moved below markup for proper element availability -->
|
||||
|
||||
<!-- Land Summary -->
|
||||
{% set land = summary.land_summary if summary else None %}
|
||||
{% if land %}
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Land Summary</h5>
|
||||
<div class="muted" style="font-weight:600; margin-bottom:.35rem;">
|
||||
{{ land.headline or ('Lands: ' ~ (land.traditional or 0)) }}
|
||||
</div>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:.75rem; align-items:flex-start;">
|
||||
<div class="muted">Traditional land slots: <strong>{{ land.traditional or 0 }}</strong></div>
|
||||
<div class="muted">MDFC land additions: <strong>{{ land.dfc_lands or 0 }}</strong></div>
|
||||
<div class="muted">Total with MDFCs: <strong>{{ land.with_dfc or land.traditional or 0 }}</strong></div>
|
||||
</div>
|
||||
{% if land.dfc_cards %}
|
||||
<details style="margin-top:.5rem;">
|
||||
<summary>MDFC mana sources ({{ land.dfc_cards|length }})</summary>
|
||||
<ul style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
|
||||
{% for card in land.dfc_cards %}
|
||||
{% set extra = card.adds_extra_land or card.counts_as_extra %}
|
||||
{% set colors = card.colors or [] %}
|
||||
<li class="muted" style="display:flex; gap:.5rem; flex-wrap:wrap; align-items:flex-start;">
|
||||
<span class="chip"><span class="dot" style="background:#10b981;"></span> {{ card.name }} ×{{ card.count or 1 }}</span>
|
||||
<span>Colors: {{ colors|join(', ') if colors else '–' }}</span>
|
||||
{% if extra %}
|
||||
<span class="chip" style="background:#0f172a; border-color:#34d399; color:#a7f3d0;">{{ card.note or 'Adds extra land slot' }}</span>
|
||||
{% else %}
|
||||
<span class="chip" style="background:#111827; border-color:#60a5fa; color:#bfdbfe;">{{ card.note or 'Counts as land slot' }}</span>
|
||||
{% endif %}
|
||||
{% if card.faces %}
|
||||
<ul style="list-style:none; padding:0; margin:.2rem 0 0; display:grid; gap:.15rem; flex:1 0 100%;">
|
||||
{% for face in card.faces %}
|
||||
{% set face_name = face.get('face') or face.get('faceName') or 'Face' %}
|
||||
{% set face_type = face.get('type') or '–' %}
|
||||
{% set mana_cost = face.get('mana_cost') %}
|
||||
{% set mana_value = face.get('mana_value') %}
|
||||
{% set produces = face.get('produces_mana') %}
|
||||
<li style="font-size:0.85rem; color:#e5e7eb; opacity:.85;">
|
||||
<span>{{ face_name }}</span>
|
||||
<span>— {{ face_type }}</span>
|
||||
{% if mana_cost %}<span>• Mana Cost: {{ mana_cost }}</span>{% endif %}
|
||||
{% if mana_value is not none %}<span>• MV: {{ mana_value }}</span>{% endif %}
|
||||
{% if produces %}<span>• Produces mana</span>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Mana Overview Row: Pips • Sources • Curve -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Mana Overview</h5>
|
||||
|
|
@ -144,7 +210,11 @@
|
|||
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in c_cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% set label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %}
|
||||
{% if c.dfc %}
|
||||
{% set label = label ~ ' (DFC)' %}
|
||||
{% endif %}
|
||||
{% set _ = parts.append(label) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue