feat(web,docs): visual summaries (curve, pips/sources incl. 'C', non‑land sources), tooltip copy, favicon; diagnostics (/healthz, request‑id, global handlers); fetches excluded, basics CSV fallback, list highlight polish; README/DOCKER/release-notes/CHANGELOG updated

This commit is contained in:
matt 2025-08-26 20:00:07 -07:00
parent 625f6abb13
commit 8d1f6a8ac4
27 changed files with 1704 additions and 154 deletions

View file

@ -66,13 +66,15 @@ def normalize_theme_list(raw) -> list[str]:
def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[str, Dict[str, int]]:
"""Build a matrix mapping land name -> {color: 0/1} indicating if that land
can (reliably) produce each color.
"""Build a matrix mapping card name -> {color: 0/1} indicating if that card
can (reliably) produce each color of mana on the battlefield.
Heuristics:
- Presence of basic land types in type line grants that color.
- Text containing "add one mana of any color/colour" grants all colors.
- Explicit mana symbols in rules text (e.g. "{R}") grant that color.
Notes:
- Includes lands and non-lands (artifacts/creatures/enchantments/planeswalkers) that produce mana.
- Excludes instants/sorceries (rituals) by design; this is a "source" count, not ramp burst.
- Any-color effects set W/U/B/R/G (not C). Colorless '{C}' is tracked separately.
- For lands, we also infer from basic land types in the type line. For non-lands, we rely on text.
- Fallback name mapping applies only to exact basic lands (incl. Snow-Covered) and Wastes.
Parameters
----------
@ -89,29 +91,84 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
if nm and nm not in lookup:
lookup[nm] = r
for name, entry in card_library.items():
if 'land' not in str(entry.get('Card Type', '')).lower():
continue
row = lookup.get(name, {})
tline = str(row.get('type', row.get('type_line', ''))).lower()
text_field = str(row.get('text', row.get('oracleText', ''))).lower()
colors = {c: 0 for c in COLOR_LETTERS}
if 'plains' in tline:
colors['W'] = 1
if 'island' in tline:
colors['U'] = 1
if 'swamp' in tline:
colors['B'] = 1
if 'mountain' in tline:
colors['R'] = 1
if 'forest' in tline:
colors['G'] = 1
if 'add one mana of any color' in text_field or 'add one mana of any colour' in text_field:
for k in colors:
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()
# 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()
# 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
# Keep only candidates that are lands OR whose text indicates mana production
produces_from_text = False
tf = text_field
if tf:
# Common patterns: "Add {G}", "Add {C}{C}", "Add one mana of any color/colour"
produces_from_text = (
('add one mana of any color' in tf) or
('add one mana of any colour' in tf) or
('add ' in tf and ('{w}' in tf or '{u}' in tf or '{b}' in tf or '{r}' in tf or '{g}' in tf or '{c}' in tf))
)
if not (is_land or produces_from_text):
continue
# Combine entry type and snapshot type line for robust parsing
tline = (entry_type + ' ' + tline_full).strip()
colors = {c: 0 for c in (COLOR_LETTERS + ['C'])}
# Land type-based inference
if is_land:
if 'plains' in tline:
colors['W'] = 1
if 'island' in tline:
colors['U'] = 1
if 'swamp' in tline:
colors['B'] = 1
if 'mountain' in tline:
colors['R'] = 1
if 'forest' in tline:
colors['G'] = 1
# Text-based inference for both lands and non-lands
if (
'add one mana of any color' in tf or
'add one mana of any colour' in tf or
('add' in tf and ('mana of any color' in tf or 'mana of any one color' in tf or 'any color of mana' in tf))
):
for k in COLOR_LETTERS:
colors[k] = 1
for sym, c in [(' {w}', 'W'), (' {u}', 'U'), (' {b}', 'B'), (' {r}', 'R'), (' {g}', 'G')]:
if sym in text_field:
colors[c] = 1
matrix[name] = colors
# Explicit colored/colorless symbols in add context
if 'add' in tf:
if '{w}' in tf:
colors['W'] = 1
if '{u}' in tf:
colors['U'] = 1
if '{b}' in tf:
colors['B'] = 1
if '{r}' in tf:
colors['R'] = 1
if '{g}' in tf:
colors['G'] = 1
if '{c}' in tf or 'colorless' in tf:
colors['C'] = 1
# Fallback: infer only for exact basic land names (incl. Snow-Covered) and Wastes
if not any(colors.values()) and is_land:
nm = str(name)
base = nm
if nm.startswith('Snow-Covered '):
base = nm[len('Snow-Covered '):]
mapping = {
'Plains': 'W',
'Island': 'U',
'Swamp': 'B',
'Mountain': 'R',
'Forest': 'G',
'Wastes': 'C',
}
col = mapping.get(base)
if col:
colors[col] = 1
# Only include cards that produced at least one color
if any(colors.values()):
matrix[name] = colors
return matrix

View file

@ -201,6 +201,8 @@ class ReportingMixin:
# 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
pip_cards: Dict[str, list] = {c: [] for c in ('W','U','B','R','G')}
import re as _re_local
total_pips = 0.0
for name, info in self.card_library.items():
@ -210,11 +212,14 @@ class ReportingMixin:
mana_cost = info.get('Mana Cost') or info.get('mana_cost') or ''
if not isinstance(mana_cost, str):
continue
# Track which colors appear for this card's mana cost for card listing
colors_for_card = set()
for match in _re_local.findall(r'\{([^}]+)\}', mana_cost):
sym = match.upper()
if len(sym) == 1 and sym in pip_counts:
pip_counts[sym] += 1
total_pips += 1
colors_for_card.add(sym)
elif '/' in sym:
parts = [p for p in sym.split('/') if p in pip_counts]
if parts:
@ -222,6 +227,17 @@ class ReportingMixin:
for p in parts:
pip_counts[p] += weight_each
total_pips += weight_each
colors_for_card.add(p)
elif sym.endswith('P') and len(sym) == 2: # e.g. WP (Phyrexian) -> treat as that color
base = sym[0]
if base in pip_counts:
pip_counts[base] += 1
total_pips += 1
colors_for_card.add(base)
if colors_for_card:
cnt = int(info.get('Count', 1))
for c in colors_for_card:
pip_cards[c].append({'name': name, 'count': cnt})
if total_pips <= 0:
# Fallback to even distribution across color identity
colors = [c for c in ('W','U','B','R','G') if c in (getattr(self, 'color_identity', []) or [])]
@ -238,12 +254,15 @@ class ReportingMixin:
matrix = _bu.compute_color_source_matrix(self.card_library, full_df)
except Exception:
matrix = {}
source_counts = {c: 0 for c in ('W','U','B','R','G')}
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))
for c in source_counts:
for c in source_counts.keys():
if int(flags.get(c, 0)):
source_counts[c] += copies
source_cards[c].append({'name': name, 'count': copies})
total_sources = sum(source_counts.values())
# Mana curve (non-land spells)
@ -282,10 +301,12 @@ class ReportingMixin:
'pip_distribution': {
'counts': pip_counts,
'weights': pip_weights,
'cards': pip_cards,
},
'mana_generation': {
**source_counts,
'total_sources': total_sources,
'cards': source_cards,
},
'mana_curve': {
**curve_counts,
@ -393,6 +414,15 @@ class ReportingMixin:
except Exception:
owned_set_lower = set()
# Fallback oracle text for basic lands to ensure CSV has meaningful text
BASIC_TEXT = {
'Plains': '({T}: Add {W}.)',
'Island': '({T}: Add {U}.)',
'Swamp': '({T}: Add {B}.)',
'Mountain': '({T}: Add {R}.)',
'Forest': '({T}: Add {G}.)',
'Wastes': '({T}: Add {C}.)',
}
for name, info in self.card_library.items():
base_type = info.get('Card Type') or info.get('Type', '')
base_mc = info.get('Mana Cost', '')
@ -423,6 +453,9 @@ class ReportingMixin:
power = row.get('power', '') or ''
toughness = row.get('toughness', '') or ''
text_field = row.get('text', row.get('oracleText', '')) or ''
# If still no text and this is a basic, inject fallback oracle snippet
if (not text_field) and (str(name) in BASIC_TEXT):
text_field = BASIC_TEXT[str(name)]
# Normalize and coerce text
if isinstance(text_field, str):
cleaned = text_field