mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
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:
parent
625f6abb13
commit
8d1f6a8ac4
27 changed files with 1704 additions and 154 deletions
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue