overhaul: migrated to tailwind css for css management, consolidated custom css, removed inline css, removed unneeded css, and otherwise improved page styling

This commit is contained in:
matt 2025-10-28 08:21:52 -07:00
parent f1e21873e7
commit b994978f60
81 changed files with 15784 additions and 2936 deletions

View file

@ -1,22 +1,18 @@
"""Loader for background cards derived from `background_cards.csv`."""
"""Loader for background cards derived from all_cards.parquet."""
from __future__ import annotations
import ast
import csv
import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
import re
from typing import Mapping, Tuple
from typing import Any, Mapping, Tuple
from logging_util import get_logger
from deck_builder.partner_background_utils import analyze_partner_background
from path_util import csv_dir
LOGGER = get_logger(__name__)
BACKGROUND_FILENAME = "background_cards.csv"
@dataclass(frozen=True, slots=True)
class BackgroundCard:
@ -57,7 +53,7 @@ class BackgroundCatalog:
def load_background_cards(
source_path: str | Path | None = None,
) -> BackgroundCatalog:
"""Load and cache background card data."""
"""Load and cache background card data from all_cards.parquet."""
resolved = _resolve_background_path(source_path)
try:
@ -65,7 +61,7 @@ def load_background_cards(
mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))
size = stat.st_size
except FileNotFoundError:
raise FileNotFoundError(f"Background CSV not found at {resolved}") from None
raise FileNotFoundError(f"Background data not found at {resolved}") from None
entries, version = _load_background_cards_cached(str(resolved), mtime_ns)
etag = f"{size}-{mtime_ns}-{len(entries)}"
@ -88,46 +84,49 @@ def _load_background_cards_cached(path_str: str, mtime_ns: int) -> Tuple[Tuple[B
if not path.exists():
return tuple(), "unknown"
with path.open("r", encoding="utf-8", newline="") as handle:
first_line = handle.readline()
version = "unknown"
if first_line.startswith("#"):
version = _parse_version(first_line)
else:
handle.seek(0)
reader = csv.DictReader(handle)
if reader.fieldnames is None:
return tuple(), version
entries = _rows_to_cards(reader)
try:
import pandas as pd
df = pd.read_parquet(path, engine="pyarrow")
# Filter for background cards
if 'isBackground' not in df.columns:
LOGGER.warning("isBackground column not found in %s", path)
return tuple(), "unknown"
df_backgrounds = df[df['isBackground']].copy()
if len(df_backgrounds) == 0:
LOGGER.warning("No background cards found in %s", path)
return tuple(), "unknown"
entries = _rows_to_cards(df_backgrounds)
version = "parquet"
except Exception as e:
LOGGER.error("Failed to load backgrounds from %s: %s", path, e)
return tuple(), "unknown"
frozen = tuple(entries)
return frozen, version
def _resolve_background_path(override: str | Path | None) -> Path:
"""Resolve path to all_cards.parquet."""
if override:
return Path(override).resolve()
return (Path(csv_dir()) / BACKGROUND_FILENAME).resolve()
# Use card_files/processed/all_cards.parquet
return Path("card_files/processed/all_cards.parquet").resolve()
def _parse_version(line: str) -> str:
tokens = line.lstrip("# ").strip().split()
for token in tokens:
if "=" not in token:
continue
key, value = token.split("=", 1)
if key == "version":
return value
return "unknown"
def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]:
def _rows_to_cards(df) -> list[BackgroundCard]:
"""Convert DataFrame rows to BackgroundCard objects."""
entries: list[BackgroundCard] = []
seen: set[str] = set()
for raw in reader:
if not raw:
for _, row in df.iterrows():
if row.empty:
continue
card = _row_to_card(raw)
card = _row_to_card(row)
if card is None:
continue
key = card.display_name.lower()
@ -135,20 +134,35 @@ def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]:
continue
seen.add(key)
entries.append(card)
entries.sort(key=lambda card: card.display_name)
return entries
def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None:
name = _clean_str(row.get("name"))
face_name = _clean_str(row.get("faceName")) or None
def _row_to_card(row) -> BackgroundCard | None:
"""Convert a DataFrame row to a BackgroundCard."""
# Helper to safely get values from DataFrame row
def get_val(key: str):
try:
if hasattr(row, key):
val = getattr(row, key)
# Handle pandas NA/None
if val is None or (hasattr(val, '__class__') and 'NA' in val.__class__.__name__):
return None
return val
return None
except Exception:
return None
name = _clean_str(get_val("name"))
face_name = _clean_str(get_val("faceName")) or None
display = face_name or name
if not display:
return None
type_line = _clean_str(row.get("type"))
oracle_text = _clean_multiline(row.get("text"))
raw_theme_tags = tuple(_parse_literal_list(row.get("themeTags")))
type_line = _clean_str(get_val("type"))
oracle_text = _clean_multiline(get_val("text"))
raw_theme_tags = tuple(_parse_literal_list(get_val("themeTags")))
detection = analyze_partner_background(type_line, oracle_text, raw_theme_tags)
if not detection.is_background:
return None
@ -158,18 +172,18 @@ def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None:
face_name=face_name,
display_name=display,
slug=_slugify(display),
color_identity=_parse_color_list(row.get("colorIdentity")),
colors=_parse_color_list(row.get("colors")),
mana_cost=_clean_str(row.get("manaCost")),
mana_value=_parse_float(row.get("manaValue")),
color_identity=_parse_color_list(get_val("colorIdentity")),
colors=_parse_color_list(get_val("colors")),
mana_cost=_clean_str(get_val("manaCost")),
mana_value=_parse_float(get_val("manaValue")),
type_line=type_line,
oracle_text=oracle_text,
keywords=tuple(_split_list(row.get("keywords"))),
keywords=tuple(_split_list(get_val("keywords"))),
theme_tags=tuple(tag for tag in raw_theme_tags if tag),
raw_theme_tags=raw_theme_tags,
edhrec_rank=_parse_int(row.get("edhrecRank")),
layout=_clean_str(row.get("layout")) or "normal",
side=_clean_str(row.get("side")) or None,
edhrec_rank=_parse_int(get_val("edhrecRank")),
layout=_clean_str(get_val("layout")) or "normal",
side=_clean_str(get_val("side")) or None,
)
@ -189,8 +203,19 @@ def _clean_multiline(value: object) -> str:
def _parse_literal_list(value: object) -> list[str]:
if value is None:
return []
if isinstance(value, (list, tuple, set)):
# Check if it's a numpy array (from Parquet/pandas)
is_numpy = False
try:
import numpy as np
is_numpy = isinstance(value, np.ndarray)
except ImportError:
pass
# Handle lists, tuples, sets, and numpy arrays
if isinstance(value, (list, tuple, set)) or is_numpy:
return [str(item).strip() for item in value if str(item).strip()]
text = str(value).strip()
if not text:
return []
@ -205,6 +230,17 @@ def _parse_literal_list(value: object) -> list[str]:
def _split_list(value: object) -> list[str]:
# Check if it's a numpy array (from Parquet/pandas)
is_numpy = False
try:
import numpy as np
is_numpy = isinstance(value, np.ndarray)
except ImportError:
pass
if isinstance(value, (list, tuple, set)) or is_numpy:
return [str(item).strip() for item in value if str(item).strip()]
text = _clean_str(value)
if not text:
return []
@ -213,6 +249,18 @@ def _split_list(value: object) -> list[str]:
def _parse_color_list(value: object) -> Tuple[str, ...]:
# Check if it's a numpy array (from Parquet/pandas)
is_numpy = False
try:
import numpy as np
is_numpy = isinstance(value, np.ndarray)
except ImportError:
pass
if isinstance(value, (list, tuple, set)) or is_numpy:
parts = [str(item).strip().upper() for item in value if str(item).strip()]
return tuple(parts)
text = _clean_str(value)
if not text:
return tuple()

View file

@ -62,6 +62,32 @@ def _detect_produces_mana(text: str) -> bool:
return False
def _extract_colors_from_land_type(type_line: str) -> List[str]:
"""Extract mana colors from basic land types in a type line.
Args:
type_line: Card type line (e.g., "Land — Mountain", "Land — Forest Plains")
Returns:
List of color letters (e.g., ['R'], ['G', 'W'])
"""
if not isinstance(type_line, str):
return []
type_lower = type_line.lower()
colors = []
basic_land_colors = {
'plains': 'W',
'island': 'U',
'swamp': 'B',
'mountain': 'R',
'forest': 'G',
}
for land_type, color in basic_land_colors.items():
if land_type in type_lower:
colors.append(color)
return colors
def _resolved_csv_dir(base_dir: str | None = None) -> str:
try:
if base_dir:
@ -144,7 +170,9 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
return {}
# Select only needed columns
usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName']
# M9: Added backType to detect MDFC lands where land is on back face
# M9: Added colorIdentity to extract mana colors for MDFC lands
usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName', 'backType', 'colorIdentity']
available_cols = [col for col in usecols if col in df.columns]
if not available_cols:
return {}
@ -160,7 +188,16 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
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)]
# M9: Check both type and backType for land faces
if 'backType' in multi_df.columns:
multi_df['backType'] = multi_df['backType'].fillna('').astype(str)
land_mask = (
multi_df['type'].str.contains('land', case=False, na=False) |
multi_df['backType'].str.contains('land', case=False, na=False)
)
land_rows = multi_df[land_mask]
else:
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]] = {}
@ -169,6 +206,78 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
seen: set[tuple[str, str, str]] = set()
front_is_land = False
layout_val = ''
# M9: Handle merged rows with backType
if len(group) == 1 and 'backType' in group.columns:
row = group.iloc[0]
back_type_val = str(row.get('backType', '') or '')
if back_type_val and 'land' in back_type_val.lower():
# Construct synthetic faces from merged row
front_type = str(row.get('type', '') or '')
front_text = 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
# Front face
faces.append({
'face': str(row.get('faceName', '') or name),
'side': 'a',
'type': front_type,
'text': front_text,
'mana_cost': mana_cost_val,
'mana_value': mana_value_val,
'produces_mana': _detect_produces_mana(front_text),
'is_land': 'land' in front_type.lower(),
'layout': str(row.get('layout', '') or ''),
})
# Back face (synthesized)
# M9: Use colorIdentity column for MDFC land colors (more reliable than parsing type line)
color_identity_raw = row.get('colorIdentity', [])
if isinstance(color_identity_raw, str):
# Handle string format like "['G']" or "G"
try:
import ast
color_identity_raw = ast.literal_eval(color_identity_raw)
except Exception:
color_identity_raw = [c.strip() for c in color_identity_raw.split(',') if c.strip()]
back_face_colors = list(color_identity_raw) if color_identity_raw else []
# Fallback to parsing land type if colorIdentity not available
if not back_face_colors:
back_face_colors = _extract_colors_from_land_type(back_type_val)
faces.append({
'face': name.split(' // ')[1] if ' // ' in name else 'Back',
'side': 'b',
'type': back_type_val,
'text': '', # Not available in merged row
'mana_cost': '',
'mana_value': None,
'produces_mana': True, # Assume land produces mana
'is_land': True,
'layout': str(row.get('layout', '') or ''),
'colors': back_face_colors, # M9: Color information for mana sources
})
front_is_land = 'land' in front_type.lower()
layout_val = str(row.get('layout', '') or '')
mapping[name] = {
'faces': faces,
'front_is_land': front_is_land,
'layout': layout_val,
'colors': back_face_colors, # M9: Store colors at top level for easy access
}
continue
# Original logic for multi-row format
for _, row in group.iterrows():
side_raw = str(row.get('side', '') or '').strip()
side_key = side_raw.lower()
@ -332,8 +441,13 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
if hasattr(row, 'get'):
row_type_raw = row.get('type', row.get('type_line', '')) or ''
tline_full = str(row_type_raw).lower()
# M9: Check backType for MDFC land detection
back_type_raw = ''
if hasattr(row, 'get'):
back_type_raw = row.get('backType', '') or ''
back_type = str(back_type_raw).lower()
# Land or permanent that could produce mana via text
is_land = ('land' in entry_type) or ('land' in tline_full)
is_land = ('land' in entry_type) or ('land' in tline_full) or ('land' in back_type)
base_is_land = is_land
text_field_raw = ''
if hasattr(row, 'get'):
@ -363,7 +477,8 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
if face_types or face_texts:
is_land = True
text_field = text_field_raw.lower().replace('\n', ' ')
# Skip obvious non-permanents (rituals etc.)
# Skip obvious non-permanents (rituals etc.) - but NOT if any face is a land
# M9: If is_land is True (from backType check), we keep it regardless of front face type
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
# Keep only candidates that are lands OR whose text indicates mana production
@ -437,6 +552,12 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
colors['_dfc_land'] = True
if not (base_is_land or dfc_entry.get('front_is_land')):
colors['_dfc_counts_as_extra'] = True
# M9: Extract colors from DFC face metadata (back face land colors)
dfc_colors = dfc_entry.get('colors', [])
if dfc_colors:
for color in dfc_colors:
if color in colors:
colors[color] = 1
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

View file

@ -363,7 +363,14 @@ def _normalize_color_identity(value: Any) -> tuple[str, ...]:
def _normalize_string_sequence(value: Any) -> tuple[str, ...]:
if value is None:
return tuple()
if isinstance(value, (list, tuple, set)):
# Handle numpy arrays, lists, tuples, sets, and other sequences
try:
import numpy as np
is_numpy = isinstance(value, np.ndarray)
except ImportError:
is_numpy = False
if isinstance(value, (list, tuple, set)) or is_numpy:
items = list(value)
else:
text = _safe_str(value)

View file

@ -543,6 +543,9 @@ class ReportingMixin:
mf_info = {}
faces_meta = list(mf_info.get('faces', [])) if isinstance(mf_info, dict) else []
layout_val = mf_info.get('layout') if isinstance(mf_info, dict) else None
# M9: If no colors found from mana production, try extracting from face metadata
if not card_colors and isinstance(mf_info, dict):
card_colors = list(mf_info.get('colors', []))
dfc_land_lookup[name] = {
'adds_extra_land': counts_as_extra,
'counts_as_land': not counts_as_extra,
@ -681,13 +684,14 @@ class ReportingMixin:
'faces': faces_meta,
'layout': layout_val,
})
if adds_extra:
dfc_extra_total += copies
# M9: Count ALL MDFC lands for land summary
dfc_extra_total += copies
total_sources = sum(source_counts.values())
traditional_lands = type_counts.get('Land', 0)
# M9: dfc_extra_total now contains ALL MDFC lands, not just extras
land_summary = {
'traditional': traditional_lands,
'dfc_lands': dfc_extra_total,
'dfc_lands': dfc_extra_total, # M9: Count of all MDFC lands
'with_dfc': traditional_lands + dfc_extra_total,
'dfc_cards': dfc_details,
'headline': build_land_headline(traditional_lands, dfc_extra_total, traditional_lands + dfc_extra_total),