Add colorless commander filtering and display fixes

This commit is contained in:
matt 2025-10-16 11:20:27 -07:00
parent 2eab6ab653
commit bec984ce3e
9 changed files with 211 additions and 18 deletions

View file

@ -9,16 +9,18 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased] ## [Unreleased]
### Summary ### Summary
_No unreleased changes yet._ Improved colorless commander support with automatic card filtering and display fixes.
### Added ### Added
_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
### Changed - Only applies to colorless identity commanders (Karn, Kozilek, Liberator, etc.)
_No unreleased changes yet._
### Fixed ### 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 ## [2.8.0] - 2025-10-15
### Summary ### Summary

View file

@ -1,13 +1,15 @@
# MTG Python Deckbuilder ${VERSION} # MTG Python Deckbuilder ${VERSION}
### Summary ### Summary
_No unreleased changes yet._ Improved colorless commander support with automatic card filtering and display fixes.
### Added ### Added
_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
### Changed - Only applies to colorless identity commanders (Karn, Kozilek, Liberator, etc.)
_No unreleased changes yet._
### Fixed ### 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

View file

@ -1063,8 +1063,11 @@ class DeckBuilder(
if isinstance(raw_ci, list): if isinstance(raw_ci, list):
colors_list = [str(c).strip().upper() for c in raw_ci] colors_list = [str(c).strip().upper() for c in raw_ci]
elif isinstance(raw_ci, str) and raw_ci.strip(): 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 # 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("'[] ")] colors_list = [c.strip().strip("'[] ").upper() for c in raw_ci.split(',') if c.strip().strip("'[] ")]
else: else:
colors_list = [c.upper() for c in raw_ci if c.isalpha()] colors_list = [c.upper() for c in raw_ci if c.isalpha()]
@ -1136,10 +1139,18 @@ class DeckBuilder(
required = getattr(bc, 'CSV_REQUIRED_COLUMNS', []) required = getattr(bc, 'CSV_REQUIRED_COLUMNS', [])
from path_util import csv_dir as _csv_dir from path_util import csv_dir as _csv_dir
base = _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: for stem in self.files_to_load:
path = f"{base}/{stem}_cards.csv" path = f"{base}/{stem}_cards.csv"
try: try:
df = pd.read_csv(path) df = pd.read_csv(path, converters=converters)
if required: if required:
missing = [c for c in required if c not in df.columns] missing = [c for c in required if c not in df.columns]
if missing: if missing:
@ -1175,6 +1186,54 @@ class DeckBuilder(
self.output_func(f"Owned-only mode: failed to filter combined pool: {_e}") 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 # 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) # Apply exclude card filtering (M0.5: Phase 1 - Exclude Only)
if hasattr(self, 'exclude_cards') and self.exclude_cards: if hasattr(self, 'exclude_cards') and self.exclude_cards:
try: try:

View file

@ -286,7 +286,7 @@ COLORED_MANA_SYMBOLS: Final[List[str]] = ['{w}','{u}','{b}','{r}','{g}']
# Basic Lands # Basic Lands
BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest'] BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest', 'Wastes']
# Basic land mappings # Basic land mappings
COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = { COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = {

View file

@ -159,7 +159,8 @@ class ColorBalanceMixin:
self.output_func(" (No viable swaps executed.)") self.output_func(" (No viable swaps executed.)")
# Always consider basic-land rebalance when requested # 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: try:
basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {}) 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()} basics_present = {nm: entry for nm, entry in self.card_library.items() if nm in basic_map.values()}

View file

@ -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",
]

View file

@ -1072,6 +1072,9 @@ METADATA_TAG_ALLOWLIST: set[str] = {
# Cost reduction diagnostics (from Applied: namespace) # Cost reduction diagnostics (from Applied: namespace)
'Applied: Cost Reduction', 'Applied: Cost Reduction',
# Colorless commander filtering (M1)
'Useless in Colorless',
# Kindred-specific protection metadata (from M2) # Kindred-specific protection metadata (from M2)
# Format: "{CreatureType}s Gain Protection" # Format: "{CreatureType}s Gain Protection"
# These are auto-generated for kindred-specific protection grants # These are auto-generated for kindred-specific protection grants

View file

@ -16,6 +16,7 @@ from . import regex_patterns as rgx
from . import tag_constants from . import tag_constants
from . import tag_utils from . import tag_utils
from .bracket_policy_applier import apply_bracket_policy_tags 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 from .multi_face_merger import merge_multi_face_rows
import logging_util import logging_util
from file_setup import setup 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 (from config/card_lists/*.json)
apply_bracket_policy_tags(df) apply_bracket_policy_tags(df)
# Apply colorless filter tags (M1: Useless in Colorless)
apply_colorless_filter_tags(df)
print('\n====================\n') print('\n====================\n')
# Merge multi-face entries before final ordering (feature-flagged) # Merge multi-face entries before final ordering (feature-flagged)

View file

@ -321,8 +321,11 @@ def commander_hover_context(
commander_color_label = str(combined_info.get("color_label") or "").strip() commander_color_label = str(combined_info.get("color_label") or "").strip()
if not commander_color_label and commander_color_identity: if not commander_color_label and commander_color_identity:
commander_color_label = " / ".join(commander_color_identity) commander_color_label = " / ".join(commander_color_identity)
if has_combined and not commander_color_label: # M5: Set colorless label for ANY commander with empty color identity (not just partner/combined)
commander_color_label = "Colorless (C)" 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_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 "" commander_partner_mode = str(combined_info.get("partner_mode") or "").strip() if has_combined else ""