From bec984ce3e87c86d69adf7551600c303cbcbf824 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 16 Oct 2025 11:20:27 -0700 Subject: [PATCH] Add colorless commander filtering and display fixes --- CHANGELOG.md | 14 ++- RELEASE_NOTES_TEMPLATE.md | 14 ++- code/deck_builder/builder.py | 63 +++++++++- code/deck_builder/builder_constants.py | 2 +- .../phases/phase5_color_balance.py | 3 +- code/tagging/colorless_filter_applier.py | 119 ++++++++++++++++++ code/tagging/tag_constants.py | 3 + code/tagging/tagger.py | 4 + code/web/services/build_utils.py | 7 +- 9 files changed, 211 insertions(+), 18 deletions(-) create mode 100644 code/tagging/colorless_filter_applier.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e16d99..af315c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,18 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Summary -_No unreleased changes yet._ +Improved colorless commander support with automatic card filtering and display fixes. ### Added -_No unreleased changes yet._ - -### Changed -_No unreleased changes yet._ +- **Colorless Commander Filtering**: 25 cards that don't work in colorless decks are now automatically excluded + - Filters out cards like Arcane Signet, Commander's Sphere, and medallions that reference "commander's color identity" or colored spells + - Only applies to colorless identity commanders (Karn, Kozilek, Liberator, etc.) ### Fixed -_No unreleased changes yet._ +- **Colorless Commander Display**: Fixed three bugs affecting colorless commander decks + - Color identity now displays correctly (grey "C" button with "Colorless" label) + - Wastes now correctly added as basic lands in colorless decks + - Colored basics (Plains, Island, etc.) no longer incorrectly added to colorless decks ## [2.8.0] - 2025-10-15 ### Summary diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 27d6e5b..cbc14d3 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,13 +1,15 @@ # MTG Python Deckbuilder ${VERSION} ### Summary -_No unreleased changes yet._ +Improved colorless commander support with automatic card filtering and display fixes. ### Added -_No unreleased changes yet._ - -### Changed -_No unreleased changes yet._ +- **Colorless Commander Filtering**: 25 cards that don't work in colorless decks are now automatically excluded + - Filters out cards like Arcane Signet, Commander's Sphere, and medallions that reference "commander's color identity" or colored spells + - Only applies to colorless identity commanders (Karn, Kozilek, Liberator, etc.) ### Fixed -_No unreleased changes yet._ +- **Colorless Commander Display**: Fixed three bugs affecting colorless commander decks + - Color identity now displays correctly (grey "C" button with "Colorless" label) + - Wastes now correctly added as basic lands in colorless decks + - Colored basics (Plains, Island, etc.) no longer incorrectly added to colorless decks diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index b08a718..c5f535f 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -1063,8 +1063,11 @@ class DeckBuilder( if isinstance(raw_ci, list): colors_list = [str(c).strip().upper() for c in raw_ci] elif isinstance(raw_ci, str) and raw_ci.strip(): + # Handle the literal string "Colorless" specially (from commander_cards.csv) + if raw_ci.strip().lower() == 'colorless': + colors_list = [] # Could be formatted like "['B','G']" or 'BG'; attempt simple parsing - if ',' in raw_ci: + elif ',' in raw_ci: colors_list = [c.strip().strip("'[] ").upper() for c in raw_ci.split(',') if c.strip().strip("'[] ")] else: colors_list = [c.upper() for c in raw_ci if c.isalpha()] @@ -1136,10 +1139,18 @@ class DeckBuilder( required = getattr(bc, 'CSV_REQUIRED_COLUMNS', []) from path_util import csv_dir as _csv_dir base = _csv_dir() + + # Define converters for list columns (same as tagger.py) + converters = { + 'themeTags': pd.eval, + 'creatureTypes': pd.eval, + 'metadataTags': pd.eval # M2: Parse metadataTags column + } + for stem in self.files_to_load: path = f"{base}/{stem}_cards.csv" try: - df = pd.read_csv(path) + df = pd.read_csv(path, converters=converters) if required: missing = [c for c in required if c not in df.columns] if missing: @@ -1175,6 +1186,54 @@ class DeckBuilder( self.output_func(f"Owned-only mode: failed to filter combined pool: {_e}") # Soft prefer-owned does not filter the pool; biasing is applied later at selection time + # M2: Filter out cards useless in colorless identity decks + if self.color_identity_key == 'COLORLESS': + logger.info(f"M2 COLORLESS FILTER: Activated for color_identity_key='{self.color_identity_key}'") + try: + if 'metadataTags' in combined.columns and 'name' in combined.columns: + # Find cards with "Useless in Colorless" metadata tag + def has_useless_tag(metadata_tags): + # Handle various types: NaN, empty list, list with values + if metadata_tags is None: + return False + # Check for pandas NaN or numpy NaN + try: + import numpy as np + if isinstance(metadata_tags, float) and np.isnan(metadata_tags): + return False + except (TypeError, ValueError): + pass + # Handle empty list or numpy array + if isinstance(metadata_tags, (list, np.ndarray)): + if len(metadata_tags) == 0: + return False + return 'Useless in Colorless' in metadata_tags + return False + + useless_mask = combined['metadataTags'].apply(has_useless_tag) + useless_count = useless_mask.sum() + + if useless_count > 0: + useless_names = combined.loc[useless_mask, 'name'].tolist() + combined = combined[~useless_mask].copy() + self.output_func(f"Colorless commander: filtered out {useless_count} cards useless in colorless identity") + logger.info(f"M2 COLORLESS FILTER: Filtered out {useless_count} cards") + # Log first few cards for transparency + for name in useless_names[:3]: + self.output_func(f" - Filtered: {name}") + logger.info(f"M2 COLORLESS FILTER: Removed '{name}'") + if useless_count > 3: + self.output_func(f" - ... and {useless_count - 3} more") + else: + logger.warning(f"M2 COLORLESS FILTER: No cards found with 'Useless in Colorless' tag!") + else: + logger.warning(f"M2 COLORLESS FILTER: Missing required columns (metadataTags or name)") + except Exception as e: + self.output_func(f"Warning: Failed to apply colorless filter: {e}") + logger.error(f"M2 COLORLESS FILTER: Exception: {e}", exc_info=True) + else: + logger.info(f"M2 COLORLESS FILTER: Not activated - color_identity_key='{self.color_identity_key}' (not 'Colorless')") + # Apply exclude card filtering (M0.5: Phase 1 - Exclude Only) if hasattr(self, 'exclude_cards') and self.exclude_cards: try: diff --git a/code/deck_builder/builder_constants.py b/code/deck_builder/builder_constants.py index 6193869..8b2e5f8 100644 --- a/code/deck_builder/builder_constants.py +++ b/code/deck_builder/builder_constants.py @@ -286,7 +286,7 @@ COLORED_MANA_SYMBOLS: Final[List[str]] = ['{w}','{u}','{b}','{r}','{g}'] # Basic Lands -BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest'] +BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest', 'Wastes'] # Basic land mappings COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = { diff --git a/code/deck_builder/phases/phase5_color_balance.py b/code/deck_builder/phases/phase5_color_balance.py index bbb2085..d8c7db6 100644 --- a/code/deck_builder/phases/phase5_color_balance.py +++ b/code/deck_builder/phases/phase5_color_balance.py @@ -159,7 +159,8 @@ class ColorBalanceMixin: self.output_func(" (No viable swaps executed.)") # Always consider basic-land rebalance when requested - if rebalance_basics: + # M5: Skip rebalance for colorless commanders (they should have only Wastes) + if rebalance_basics and self.color_identity: # Only rebalance if commander has colors try: basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {}) basics_present = {nm: entry for nm, entry in self.card_library.items() if nm in basic_map.values()} diff --git a/code/tagging/colorless_filter_applier.py b/code/tagging/colorless_filter_applier.py new file mode 100644 index 0000000..c64be30 --- /dev/null +++ b/code/tagging/colorless_filter_applier.py @@ -0,0 +1,119 @@ +"""Apply 'Useless in Colorless' metadata tags to cards that don't work in colorless identity decks. + +This module identifies and tags cards using regex patterns to match oracle text: +1. Cards referencing "your commander's color identity" +2. Cards that reduce costs of colored spells +3. Cards that trigger on casting colored spells + +Examples include: +- Arcane Signet, Command Tower (commander color identity) +- Pearl/Sapphire/Jet/Ruby/Emerald Medallion (colored cost reduction) +- Oketra's/Kefnet's/Bontu's/Hazoret's/Rhonas's Monument (colored creature cost reduction) +- Shrine of Loyal Legions, etc. (colored spell triggers) +""" +from __future__ import annotations +import logging +import pandas as pd + +logger = logging.getLogger(__name__) + +# Regex patterns for cards that don't work in colorless identity decks +COLORLESS_FILTER_PATTERNS = [ + # Cards referencing "your commander's color identity" + # BUT exclude Commander's Plate (protection from colors NOT in identity = amazing in colorless!) + # and Study Hall (still draws/scrys in colorless) + r"commander'?s?\s+color\s+identity", + + # Colored cost reduction - medallions and monuments + # Matches: "white spells you cast cost", "blue creature spells you cast cost", etc. + r"(white|blue|black|red|green)\s+(creature\s+)?spells?\s+you\s+cast\s+cost.*less", + + # Colored spell triggers - shrines and similar + # Matches: "whenever you cast a white spell", etc. + r"whenever\s+you\s+cast\s+a\s+(white|blue|black|red|green)\s+spell", +] + +# Cards that should NOT be filtered despite matching patterns +# These cards actually work great in colorless decks +COLORLESS_FILTER_EXCEPTIONS = [ + "Commander's Plate", # Protection from colors NOT in identity = protection from all colors in colorless! + "Study Hall", # Still provides colorless mana and scrys when casting commander +] + +USELESS_IN_COLORLESS_TAG = "Useless in Colorless" + + +def apply_colorless_filter_tags(df: pd.DataFrame) -> None: + """Apply 'Useless in Colorless' metadata tag to cards that don't work in colorless decks. + + Uses regex patterns to identify cards in oracle text that: + - Reference "your commander's color identity" + - Reduce costs of colored spells + - Trigger on casting colored spells + + Modifies the DataFrame in-place by adding tags to the 'themeTags' column. + These tags will later be moved to 'metadataTags' during the partition phase. + + Args: + df: DataFrame with 'name', 'text', and 'themeTags' columns + + Returns: + None (modifies DataFrame in-place) + """ + if 'name' not in df.columns: + logger.warning("No 'name' column found, skipping colorless filter tagging") + return + + if 'text' not in df.columns: + logger.warning("No 'text' column found, skipping colorless filter tagging") + return + + if 'themeTags' not in df.columns: + logger.warning("No 'themeTags' column found, skipping colorless filter tagging") + return + + # Combine all patterns with OR + combined_pattern = "|".join(f"({pattern})" for pattern in COLORLESS_FILTER_PATTERNS) + + # Find cards matching any pattern + df['text'] = df['text'].fillna('') + matches_pattern = df['text'].str.contains( + combined_pattern, + case=False, + regex=True, + na=False + ) + + # Exclude cards that work well in colorless despite matching patterns + is_exception = df['name'].isin(COLORLESS_FILTER_EXCEPTIONS) + matches_pattern = matches_pattern & ~is_exception + + tagged_count = 0 + + for idx in df[matches_pattern].index: + card_name = df.at[idx, 'name'] + tags = df.at[idx, 'themeTags'] + + # Ensure themeTags is a list + if not isinstance(tags, list): + tags = [] + + # Add tag if not already present + if USELESS_IN_COLORLESS_TAG not in tags: + tags.append(USELESS_IN_COLORLESS_TAG) + df.at[idx, 'themeTags'] = tags + tagged_count += 1 + logger.debug(f"Tagged '{card_name}' with '{USELESS_IN_COLORLESS_TAG}'") + + if tagged_count > 0: + logger.info(f"Applied '{USELESS_IN_COLORLESS_TAG}' tag to {tagged_count} cards") + else: + logger.info(f"No '{USELESS_IN_COLORLESS_TAG}' tags applied (no matches or already tagged)") + + +__all__ = [ + "apply_colorless_filter_tags", + "COLORLESS_FILTER_PATTERNS", + "COLORLESS_FILTER_EXCEPTIONS", + "USELESS_IN_COLORLESS_TAG", +] diff --git a/code/tagging/tag_constants.py b/code/tagging/tag_constants.py index b197fc5..ec97bda 100644 --- a/code/tagging/tag_constants.py +++ b/code/tagging/tag_constants.py @@ -1072,6 +1072,9 @@ METADATA_TAG_ALLOWLIST: set[str] = { # Cost reduction diagnostics (from Applied: namespace) 'Applied: Cost Reduction', + # Colorless commander filtering (M1) + 'Useless in Colorless', + # Kindred-specific protection metadata (from M2) # Format: "{CreatureType}s Gain Protection" # These are auto-generated for kindred-specific protection grants diff --git a/code/tagging/tagger.py b/code/tagging/tagger.py index b5543df..3c47f1a 100644 --- a/code/tagging/tagger.py +++ b/code/tagging/tagger.py @@ -16,6 +16,7 @@ from . import regex_patterns as rgx from . import tag_constants from . import tag_utils from .bracket_policy_applier import apply_bracket_policy_tags +from .colorless_filter_applier import apply_colorless_filter_tags from .multi_face_merger import merge_multi_face_rows import logging_util from file_setup import setup @@ -493,6 +494,9 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None: # Apply bracket policy tags (from config/card_lists/*.json) apply_bracket_policy_tags(df) + + # Apply colorless filter tags (M1: Useless in Colorless) + apply_colorless_filter_tags(df) print('\n====================\n') # Merge multi-face entries before final ordering (feature-flagged) diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py index 6117d8d..04395f3 100644 --- a/code/web/services/build_utils.py +++ b/code/web/services/build_utils.py @@ -321,8 +321,11 @@ def commander_hover_context( commander_color_label = str(combined_info.get("color_label") or "").strip() if not commander_color_label and commander_color_identity: commander_color_label = " / ".join(commander_color_identity) - if has_combined and not commander_color_label: - commander_color_label = "Colorless (C)" + # M5: Set colorless label for ANY commander with empty color identity (not just partner/combined) + if not commander_color_label and (has_combined or commander_name): + # Empty color_identity list means colorless + if not commander_color_identity: + commander_color_label = "Colorless (C)" commander_color_code = str(combined_info.get("color_code") or "").strip() if has_combined else "" commander_partner_mode = str(combined_info.get("partner_mode") or "").strip() if has_combined else ""