diff --git a/.env.example b/.env.example
index 43dbd8c..2fcc200 100644
--- a/.env.example
+++ b/.env.example
@@ -92,6 +92,12 @@ WEB_AUTO_REFRESH_DAYS=7 # dockerhub: WEB_AUTO_REFRESH_DAYS="7"
WEB_TAG_PARALLEL=1 # dockerhub: WEB_TAG_PARALLEL="1"
WEB_TAG_WORKERS=2 # dockerhub: WEB_TAG_WORKERS="4"
WEB_AUTO_ENFORCE=0 # dockerhub: WEB_AUTO_ENFORCE="0"
+
+# Tagging Refinement Feature Flags
+TAG_NORMALIZE_KEYWORDS=1 # dockerhub: TAG_NORMALIZE_KEYWORDS="1" # Normalize keywords & filter specialty mechanics
+TAG_PROTECTION_GRANTS=1 # dockerhub: TAG_PROTECTION_GRANTS="1" # Protection tag only for cards granting shields
+TAG_METADATA_SPLIT=1 # dockerhub: TAG_METADATA_SPLIT="1" # Separate metadata tags from themes in CSVs
+
# DFC_COMPAT_SNAPSHOT=0 # 1=write legacy unmerged MDFC snapshots alongside merged catalogs (deprecated compatibility workflow)
# WEB_CUSTOM_EXPORT_BASE= # Custom basename for exports (optional).
# THEME_CATALOG_YAML_SCAN_INTERVAL_SEC=2.0 # Poll for YAML changes (dev)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b5f4ce0..ba01974 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,27 +9,60 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased]
### Summary
+- Card tagging system improvements split metadata from gameplay themes for cleaner deck building experience
- Keyword normalization reduces specialty keyword noise by 96% while maintaining theme catalog quality
- Protection tag now focuses on cards that grant shields to others, not just those with inherent protection
- Web UI improvements: faster polling, fixed progress display, and theme refresh stability
+- **Protection System Overhaul**: Comprehensive enhancement to protection card detection, classification, and deck building
+ - Fine-grained scope metadata distinguishes self-protection from board-wide effects ("Your Permanents: Hexproof" vs "Self: Hexproof")
+ - Enhanced grant detection with Equipment/Aura patterns, phasing support, and complex trigger handling
+ - Intelligent deck builder filtering includes board-relevant protection while excluding self-only and type-specific cards
+ - Tiered pool limiting focuses on high-quality staples while maintaining variety across builds
+ - Improved scope tagging for cards with keyword-only protection effects (no grant text, just inherent keywords)
### Added
+- Metadata partition system separates diagnostic tags from gameplay themes in card data
- Keyword normalization system with smart filtering of one-off specialty mechanics
- Allowlist preserves important keywords like Flying, Myriad, and Transform
- Protection grant detection identifies cards that give Hexproof, Ward, or Indestructible to other permanents
- Automatic tagging for creature-type-specific protection (e.g., "Knights Gain Protection")
+- New `metadataTags` column in card data for bracket annotations and internal diagnostics
+- Static phasing keyword detection from keywords field (catches creatures like Breezekeeper)
+- "Other X you control have Y" protection pattern for static ability grants
+- "Enchanted creature has phasing" pattern detection
+- Chosen type blanket phasing patterns
+- Complex trigger phasing patterns (reactive, consequent, end-of-turn)
+- Protection scope filtering in deck builder (feature flag: `TAG_PROTECTION_SCOPE`) intelligently selects board-relevant protection
+- Phasing cards with "Your Permanents:" or "Targeted:" metadata now tagged as Protection and included in protection pool
+- Metadata tags temporarily visible in card hover previews for debugging (shows scope like "Your Permanents: Hexproof")
### Changed
+- Card tags now split between themes (for deck building) and metadata (for diagnostics)
- Keywords now consolidate variants (e.g., "Commander ninjutsu" becomes "Ninjutsu")
- Setup progress polling reduced from 3s to 5-10s intervals for better performance
- Theme catalog streamlined from 753 to 736 themes (-2.3%) with improved quality
- Protection tag refined to focus on 329 cards that grant shields (down from 1,166 with inherent effects)
+- Theme catalog automatically excludes metadata tags from theme suggestions
+- Grant detection now strips reminder text before pattern matching to avoid false positives
+- Deck builder protection phase now filters by scope metadata: includes "Your Permanents:", excludes "Self:" protection
+- Protection card selection now randomized per build for variety (using seeded RNG when deterministic mode enabled)
+- Protection pool now limited to ~40-50 high-quality cards (tiered selection: top 3x target + random 10-20 extras)
### Fixed
- Setup progress now shows 100% completion instead of getting stuck at 99%
- Theme catalog no longer continuously regenerates after setup completes
- Health indicator polling optimized to reduce server load
- Protection detection now correctly excludes creatures with only inherent keywords
+- Dive Down, Glint no longer falsely identified as granting to opponents (reminder text fix)
+- Drogskol Captain, Haytham Kenway now correctly get "Your Permanents" scope tags
+- 7 cards with static Phasing keyword now properly detected (Breezekeeper, Teferi's Drake, etc.)
+- Type-specific protection grants (e.g., "Knights Gain Indestructible") now correctly excluded from general protection pool
+- Protection scope filter now properly prioritizes exclusions over inclusions (fixes Knight Exemplar in non-Knight decks)
+- Inherent protection cards (Aysen Highway, Phantom Colossus, etc.) now correctly get "Self: Protection" metadata tags
+- Scope tagging now applies to ALL cards with protection effects, not just grant cards
+- Cloak of Invisibility, Teferi's Curse now get "Your Permanents: Phasing" tags
+- Shimmer now gets "Blanket: Phasing" tag for chosen type effect
+- King of the Oathbreakers now gets "Self: Phasing" tag for reactive trigger
## [2.5.2] - 2025-10-08
### Summary
diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md
index d35313a..c32861c 100644
--- a/RELEASE_NOTES_TEMPLATE.md
+++ b/RELEASE_NOTES_TEMPLATE.md
@@ -1,45 +1,61 @@
-# MTG Pyt### Added
-- Keywo### Changed
-- Keywords consolidate variants (e.g., "Commander ninjutsu" → "Ninjutsu") for consistent theme matching
-- Protection tag refined to focus on shield-granting cards (329 cards vs 1,166 previously)
-- Theme catalog streamlined with improved quality (736 themes, down 2.3%)
-- Commander search and theme picker now share an intelligent debounce to prevent redundant requests while typing
-- Card grids adopt modern containment rules to minimize layout recalculations on large decks
-- Include/exclude buttons respond immediately with optimistic updates, reconciling gracefully if the server disagrees
-- Frequently accessed views, like the commander catalog default, now pull from an in-memory cache for sub-200 ms reloads
-- Deck review loads in focused chunks, keeping the initial page lean while analytics stream progressively
-- Chart hover zones expand to full column width for easier interactionnup filters out one-off specialty mechanics (like set-specific ability words) while keeping evergreen abilities
-- Protection grant detection identifies cards that give Hexproof, Ward, or other shields to your permanents
-- Creature-type-specific protection automatically tagged (e.g., "Knights Gain Protection" for tribal strategies)
-- Skeleton placeholders accept `data-skeleton-label` microcopy and only surface after ~400 ms across the build wizard, stage navigator, and alternatives panel
-- Must-have toggle API (`/build/must-haves/toggle`), telemetry ingestion route (`/telemetry/events`), and structured logging helpers capture include/exclude beacons
-- Commander catalog results wrap in a deferred skeleton list while commander art lazy-loads via a new `IntersectionObserver` helper in `code/web/static/app.js`
-- Collapsible accordions for Mana Overview and Test Hand sections defer heavy analytics until they are expanded
-- Click-to-pin chart tooltips keep comparisons anchored and add copy-friendly working buttons
-- Virtualized card lists automatically render only visible items once 12+ cards are presentkbuilder ${VERSION}
+# MTG Python Deckbuilder ${VERSION}
+
+## [Unreleased]
### Summary
-- Smarter card tagging: Keywords are cleaner (96% noise reduction) and Protection now highlights cards that actually grant shields to your board
-- Builder responsiveness upgrades: smarter HTMX caching, shared debounce helpers, and virtualization hints keep long card lists responsive
-- Commander catalog now ships skeleton placeholders, lazy commander art loading, and cached default results for faster repeat visits
-- Deck summary streams via an HTMX fragment while virtualization powers summary lists without loading every row up front
-- Mana analytics load on demand with collapsible sections and interactive chart tooltips that support click-to-pin comparisons
+- Card tagging improvements separate gameplay themes from internal metadata for cleaner deck building
+- Keyword cleanup reduces specialty keyword noise by 96% while keeping important mechanics
+- Protection tag now highlights cards that grant shields to your board, not just inherent protection
+- **Protection System Overhaul**: Smarter card detection, scope-aware filtering, and focused pool selection deliver consistent, high-quality protection card recommendations
+ - Deck builder distinguishes between board-wide protection and self-only effects using fine-grained metadata
+ - Intelligent pool limiting focuses on high-quality staples while maintaining variety across builds
+ - Scope-aware filtering automatically excludes self-protection and type-specific cards that don't match your deck
+ - Enhanced detection handles Equipment, Auras, phasing effects, and complex triggers correctly
+- Web UI responsiveness upgrades with smarter caching and streamlined loading
### Added
-- Skeleton placeholders accept `data-skeleton-label` microcopy and only surface after ~400 ms across the build wizard, stage navigator, and alternatives panel.
-- Must-have toggle API (`/build/must-haves/toggle`), telemetry ingestion route (`/telemetry/events`), and structured logging helpers capture include/exclude beacons.
-- Commander catalog results wrap in a deferred skeleton list while commander art lazy-loads via a new `IntersectionObserver` helper in `code/web/static/app.js`.
-- Collapsible accordions for Mana Overview and Test Hand sections defer heavy analytics until they are expanded.
-- Click-to-pin chart tooltips keep comparisons anchored and add copy-friendly working buttons.
-- Virtualized card lists automatically render only visible items once 12+ cards are present.
+- Metadata partition keeps internal tags separate from gameplay themes
+- Keyword normalization filters out one-off specialty mechanics while keeping evergreen abilities
+- Protection grant detection identifies cards that give Hexproof, Ward, or other shields to your permanents
+- Creature-type-specific protection automatically tagged (e.g., "Knights Gain Protection" for tribal strategies)
+- Protection scope filtering (feature flag: `TAG_PROTECTION_SCOPE`) automatically excludes self-only protection like Svyelun
+- Phasing cards with protective effects now included in protection pool (e.g., cards that phase out your permanents)
+- Debug mode: Hover over cards to see metadata tags showing protection scope (e.g., "Your Permanents: Hexproof")
+- Skeleton placeholders with smart timing across build wizard and commander catalog
+- Must-have toggle API with telemetry tracking for include/exclude interactions
+- Commander catalog lazy-loads art and caches frequently accessed views
+- Collapsible sections for mana analytics defer loading until expanded
+- Click-to-pin chart tooltips for easier card comparisons
+- Virtualized card lists handle large decks smoothly
### Changed
-- Commander search and theme picker now share an intelligent debounce to prevent redundant requests while typing.
-- Card grids adopt modern containment rules to minimize layout recalculations on large decks.
-- Include/exclude buttons respond immediately with optimistic updates, reconciling gracefully if the server disagrees.
-- Frequently accessed views, like the commander catalog default, now pull from an in-memory cache for sub-200 ms reloads.
-- Deck review loads in focused chunks, keeping the initial page lean while analytics stream progressively.
-- Chart hover zones expand to full column width for easier interaction.
+- Card tags now split between themes (for deck building) and metadata (for diagnostics)
+- Keywords consolidate variants (e.g., "Commander ninjutsu" → "Ninjutsu") for consistent theme matching
+- Protection tag refined to focus on shield-granting cards (329 cards vs 1,166 previously)
+- Deck builder protection phase filters by scope: includes "Your Permanents:", excludes "Self:" protection
+- Protection card selection randomized for variety across builds (deterministic when using seeded mode)
+- Theme catalog streamlined with improved quality (736 themes, down 2.3%)
+- Theme catalog automatically excludes metadata tags from suggestions
+- Commander search and theme picker share intelligent debounce to prevent redundant requests
+- Include/exclude buttons respond immediately with optimistic updates
+- Commander catalog default view loads from cache for sub-200ms response times
+- Deck review loads in focused chunks for faster initial page loads
+- Chart hover zones expanded for easier interaction
### Fixed
-- _None_
+### Fixed
+- Setup progress correctly displays 100% upon completion
+- Theme catalog refresh stability improved after initial setup
+- Server polling optimized for reduced load
+- Protection detection accurately filters inherent vs granted effects
+- Protection scope detection improvements for 11+ cards:
+ - Dive Down, Glint no longer falsely marked as opponent grants (reminder text now stripped)
+ - Drogskol Captain and similar cards with "Other X you control have Y" patterns now tagged correctly
+ - 7 cards with static Phasing keyword now detected (Breezekeeper, Teferi's Drake, etc.)
+ - Cloak of Invisibility and Teferi's Curse now get "Your Permanents: Phasing" tags
+ - Shimmer now gets "Blanket: Phasing" for chosen type effect
+ - King of the Oathbreakers reactive trigger now properly detected
+- Type-specific protection (Knight Exemplar, Timber Protector) no longer added to non-matching decks
+- Deck builder correctly excludes "Self:" protection cards (e.g., Svyelun) from protection pool
+- Inherent protection cards (Aysen Highway, Phantom Colossus) now correctly receive scope metadata tags
+- Protection pool now intelligently limited to focus on high-quality, relevant cards for your deck
diff --git a/_tmp_check_metrics.py b/_tmp_check_metrics.py
deleted file mode 100644
index 8bf5e40..0000000
--- a/_tmp_check_metrics.py
+++ /dev/null
@@ -1,5 +0,0 @@
-import urllib.request, json
-raw = urllib.request.urlopen("http://localhost:8000/themes/metrics").read().decode()
-js=json.loads(raw)
-print('example_enforcement_active=', js.get('preview',{}).get('example_enforcement_active'))
-print('example_enforce_threshold_pct=', js.get('preview',{}).get('example_enforce_threshold_pct'))
diff --git a/_tmp_run_orchestrator.py b/_tmp_run_orchestrator.py
deleted file mode 100644
index 854aa1d..0000000
--- a/_tmp_run_orchestrator.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from code.web.services import orchestrator
-orchestrator._ensure_setup_ready(print, force=False)
-print('DONE')
\ No newline at end of file
diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py
index a7a5d53..b08a718 100644
--- a/code/deck_builder/builder.py
+++ b/code/deck_builder/builder.py
@@ -1759,6 +1759,7 @@ class DeckBuilder(
entry['Synergy'] = synergy
else:
# If no tags passed attempt enrichment from filtered pool first, then full snapshot
+ metadata_tags: list[str] = []
if not tags:
# Use filtered pool (_combined_cards_df) instead of unfiltered (_full_cards_df)
# This ensures exclude filtering is respected during card enrichment
@@ -1774,6 +1775,13 @@ class DeckBuilder(
# tolerate comma separated
parts = [p.strip().strip("'\"") for p in raw_tags.split(',')]
tags = [p for p in parts if p]
+ # M5: Extract metadata tags for web UI display
+ raw_meta = row_match.iloc[0].get('metadataTags', [])
+ if isinstance(raw_meta, list):
+ metadata_tags = [str(t).strip() for t in raw_meta if str(t).strip()]
+ elif isinstance(raw_meta, str) and raw_meta.strip():
+ parts = [p.strip().strip("'\"") for p in raw_meta.split(',')]
+ metadata_tags = [p for p in parts if p]
except Exception:
pass
# Enrich missing type and mana_cost for accurate categorization
@@ -1811,6 +1819,7 @@ class DeckBuilder(
'Mana Value': mana_value,
'Creature Types': creature_types,
'Tags': tags,
+ 'MetadataTags': metadata_tags, # M5: Store metadata tags for web UI
'Commander': is_commander,
'Count': 1,
'Role': (role or ('commander' if is_commander else None)),
diff --git a/code/deck_builder/phases/phase4_spells.py b/code/deck_builder/phases/phase4_spells.py
index 76ff0c9..3ec39fb 100644
--- a/code/deck_builder/phases/phase4_spells.py
+++ b/code/deck_builder/phases/phase4_spells.py
@@ -539,6 +539,10 @@ class SpellAdditionMixin:
"""Add protection spells to the deck.
Selects cards tagged as 'protection', prioritizing by EDHREC rank and mana value.
Avoids duplicates and commander card.
+
+ M5: When TAG_PROTECTION_SCOPE is enabled, filters to include only cards that
+ protect your board (Your Permanents:, {Type} Gain) and excludes self-only or
+ opponent protection cards.
"""
target = self.ideal_counts.get('protection', 0)
if target <= 0 or self._combined_cards_df is None:
@@ -546,14 +550,88 @@ class SpellAdditionMixin:
already = {n.lower() for n in self.card_library.keys()}
df = self._combined_cards_df.copy()
df['_ltags'] = df.get('themeTags', []).apply(bu.normalize_tag_cell)
- pool = df[df['_ltags'].apply(lambda tags: any('protection' in t for t in tags))]
+
+ # M5: Apply scope-based filtering if enabled
+ import settings as s
+ if getattr(s, 'TAG_PROTECTION_SCOPE', True):
+ # Check metadata tags for scope information
+ df['_meta_tags'] = df.get('metadataTags', []).apply(bu.normalize_tag_cell)
+
+ def is_board_relevant_protection(row):
+ """Check if protection card helps protect your board.
+
+ Includes:
+ - Cards with "Your Permanents:" metadata (board-wide protection)
+ - Cards with "Blanket:" metadata (affects all permanents)
+ - Cards with "Targeted:" metadata (can target your stuff)
+ - Legacy cards without metadata tags
+
+ Excludes:
+ - "Self:" protection (only protects itself)
+ - "Opponent Permanents:" protection (helps opponents)
+ - Type-specific grants like "Knights Gain" (too narrow, handled by kindred synergies)
+ """
+ theme_tags = row.get('_ltags', [])
+ meta_tags = row.get('_meta_tags', [])
+
+ # First check if it has general protection tag
+ has_protection = any('protection' in t for t in theme_tags)
+ if not has_protection:
+ return False
+
+ # INCLUDE: Board-relevant scopes
+ # "Your Permanents:", "Blanket:", "Targeted:"
+ has_board_scope = any(
+ 'your permanents:' in t or 'blanket:' in t or 'targeted:' in t
+ for t in meta_tags
+ )
+
+ # EXCLUDE: Self-only, opponent protection, or type-specific grants
+ # Check for type-specific grants FIRST (highest priority exclusion)
+ has_type_specific = any(
+ ' gain ' in t.lower() # "Knights Gain", "Treefolk Gain", etc.
+ for t in meta_tags
+ )
+
+ has_excluded_scope = any(
+ 'self:' in t or
+ 'opponent permanents:' in t
+ for t in meta_tags
+ )
+
+ # Include if board-relevant, or if no scope tags (legacy cards)
+ # ALWAYS exclude type-specific grants (too narrow for general protection)
+ if meta_tags:
+ # Has metadata - use it for filtering
+ # Exclude if type-specific OR self/opponent
+ if has_type_specific or has_excluded_scope:
+ return False
+ # Otherwise include if board-relevant
+ return has_board_scope
+ else:
+ # No metadata - legacy card, include by default
+ return True
+
+ pool = df[df.apply(is_board_relevant_protection, axis=1)]
+
+ # Log scope filtering stats
+ original_count = len(df[df['_ltags'].apply(lambda tags: any('protection' in t for t in tags))])
+ filtered_count = len(pool)
+ if original_count > filtered_count:
+ self.output_func(f"Protection scope filter: {filtered_count}/{original_count} cards (excluded {original_count - filtered_count} self-only/opponent cards)")
+ else:
+ # Legacy behavior: include all cards with 'protection' tag
+ pool = df[df['_ltags'].apply(lambda tags: any('protection' in t for t in tags))]
+
pool = pool[~pool['type'].fillna('').str.contains('Land', case=False, na=False)]
commander_name = getattr(self, 'commander', None)
if commander_name:
pool = pool[pool['name'] != commander_name]
pool = self._apply_bracket_pre_filters(pool)
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
+
self._debug_dump_pool(pool, 'protection')
+
try:
if str(os.getenv('DEBUG_SPELL_POOLS', '')).strip().lower() in {"1","true","yes","on"}:
names = pool['name'].astype(str).head(30).tolist()
@@ -580,6 +658,48 @@ class SpellAdditionMixin:
if existing >= target and to_add == 0:
return
target = to_add if existing < target else to_add
+
+ # M5: Limit pool size to manageable tier-based selection
+ # Strategy: Top tier (3x target) + random deeper selection
+ # This keeps the pool focused on high-quality options (~50-70 cards typical)
+ original_pool_size = len(pool)
+ if len(pool) > 0 and target > 0:
+ try:
+ # Tier 1: Top quality cards (3x target count)
+ tier1_size = min(3 * target, len(pool))
+ tier1 = pool.head(tier1_size).copy()
+
+ # Tier 2: Random additional cards from remaining pool (10-20 cards)
+ if len(pool) > tier1_size:
+ remaining_pool = pool.iloc[tier1_size:].copy()
+ tier2_size = min(
+ self.rng.randint(10, 20) if hasattr(self, 'rng') and self.rng else 15,
+ len(remaining_pool)
+ )
+ if hasattr(self, 'rng') and self.rng and len(remaining_pool) > tier2_size:
+ # Use random.sample() to select random indices from the remaining pool
+ tier2_indices = self.rng.sample(range(len(remaining_pool)), tier2_size)
+ tier2 = remaining_pool.iloc[tier2_indices]
+ else:
+ tier2 = remaining_pool.head(tier2_size)
+ pool = tier1._append(tier2, ignore_index=True)
+ else:
+ pool = tier1
+
+ if len(pool) != original_pool_size:
+ self.output_func(f"Protection pool limited: {len(pool)}/{original_pool_size} cards (tier1: {tier1_size}, tier2: {len(pool) - tier1_size})")
+ except Exception as e:
+ self.output_func(f"Warning: Pool limiting failed, using full pool: {e}")
+
+ # Shuffle pool for variety across builds (using seeded RNG for determinism)
+ try:
+ if hasattr(self, 'rng') and self.rng is not None:
+ pool_list = pool.to_dict('records')
+ self.rng.shuffle(pool_list)
+ import pandas as pd
+ pool = pd.DataFrame(pool_list)
+ except Exception:
+ pass
added = 0
added_names: List[str] = []
for _, r in pool.iterrows():
diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py
index c1fa136..b71fcc0 100644
--- a/code/deck_builder/phases/phase6_reporting.py
+++ b/code/deck_builder/phases/phase6_reporting.py
@@ -878,7 +878,7 @@ class ReportingMixin:
headers = [
"Name","Count","Type","ManaCost","ManaValue","Colors","Power","Toughness",
- "Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","Text","DFCNote","Owned"
+ "Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","MetadataTags","Text","DFCNote","Owned"
]
header_suffix: List[str] = []
@@ -946,6 +946,9 @@ class ReportingMixin:
role = info.get('Role', '') or ''
tags = info.get('Tags', []) or []
tags_join = '; '.join(tags)
+ # M5: Include metadata tags in export
+ metadata_tags = info.get('MetadataTags', []) or []
+ metadata_tags_join = '; '.join(metadata_tags)
text_field = ''
colors = ''
power = ''
@@ -1014,6 +1017,7 @@ class ReportingMixin:
info.get('TriggerTag') or '',
info.get('Synergy') if info.get('Synergy') is not None else '',
tags_join,
+ metadata_tags_join, # M5: Include metadata tags
text_field[:800] if isinstance(text_field, str) else str(text_field)[:800],
dfc_note,
owned_flag
diff --git a/code/file_setup/setup.py b/code/file_setup/setup.py
index db6ad82..b377017 100644
--- a/code/file_setup/setup.py
+++ b/code/file_setup/setup.py
@@ -2,7 +2,23 @@
This module provides the main setup functionality for the MTG Python Deckbuilder
application. It handles initial setup tasks such as downloading card data,
-creating color-filtered card lists, and generating commander-eligible card lists.
+creating color-filtered card lists, and gener logger.info(f'Downloading latest card data for {color} cards')
+ download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv')
+
+ logger.info('Loading and processing card data')
+ try:
+ df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
+ except pd.errors.ParserError as e:
+ logger.warning(f'CSV parsing error encountered: {e}. Retrying with error handling...')
+ df = pd.read_csv(
+ f'{CSV_DIRECTORY}/cards.csv',
+ low_memory=False,
+ on_bad_lines='warn', # Warn about malformed rows but continue
+ encoding_errors='replace' # Replace bad encoding chars
+ )
+ logger.info('Successfully loaded card data with error handling (some rows may have been skipped)')
+
+ logger.info(f'Regenerating {color} cards CSV')der-eligible card lists.
Key Features:
- Initial setup and configuration
@@ -197,7 +213,17 @@ def regenerate_csvs_all() -> None:
download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv')
logger.info('Loading and processing card data')
- df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
+ try:
+ df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
+ except pd.errors.ParserError as e:
+ logger.warning(f'CSV parsing error encountered: {e}. Retrying with error handling...')
+ df = pd.read_csv(
+ f'{CSV_DIRECTORY}/cards.csv',
+ low_memory=False,
+ on_bad_lines='warn', # Warn about malformed rows but continue
+ encoding_errors='replace' # Replace bad encoding chars
+ )
+ logger.info(f'Successfully loaded card data with error handling (some rows may have been skipped)')
logger.info('Regenerating color identity sorted files')
save_color_filtered_csvs(df, CSV_DIRECTORY)
@@ -234,7 +260,12 @@ def regenerate_csv_by_color(color: str) -> None:
download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv')
logger.info('Loading and processing card data')
- df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
+ df = pd.read_csv(
+ f'{CSV_DIRECTORY}/cards.csv',
+ low_memory=False,
+ on_bad_lines='skip', # Skip malformed rows (MTGJSON CSV has escaping issues)
+ encoding_errors='replace' # Replace bad encoding chars
+ )
logger.info(f'Regenerating {color} cards CSV')
# Use shared utilities to base-filter once then slice color, honoring bans
diff --git a/code/settings.py b/code/settings.py
index 5731031..101b4d5 100644
--- a/code/settings.py
+++ b/code/settings.py
@@ -102,14 +102,17 @@ FILL_NA_COLUMNS: Dict[str, Optional[str]] = {
}
# ----------------------------------------------------------------------------------
-# TAGGING REFINEMENT FEATURE FLAGS (M1-M3)
+# TAGGING REFINEMENT FEATURE FLAGS (M1-M5)
# ----------------------------------------------------------------------------------
-# M1: Enable keyword normalization and singleton pruning
+# M1: Enable keyword normalization and singleton pruning (completed)
TAG_NORMALIZE_KEYWORDS = os.getenv('TAG_NORMALIZE_KEYWORDS', '1').lower() not in ('0', 'false', 'off', 'disabled')
-# M2: Enable protection grant detection (planned)
-TAG_PROTECTION_GRANTS = os.getenv('TAG_PROTECT ION_GRANTS', '0').lower() not in ('0', 'false', 'off', 'disabled')
+# M2: Enable protection grant detection (completed)
+TAG_PROTECTION_GRANTS = os.getenv('TAG_PROTECTION_GRANTS', '1').lower() not in ('0', 'false', 'off', 'disabled')
-# M3: Enable metadata/theme partition (planned)
-TAG_METADATA_SPLIT = os.getenv('TAG_METADATA_SPLIT', '0').lower() not in ('0', 'false', 'off', 'disabled')
\ No newline at end of file
+# M3: Enable metadata/theme partition (completed)
+TAG_METADATA_SPLIT = os.getenv('TAG_METADATA_SPLIT', '1').lower() not in ('0', 'false', 'off', 'disabled')
+
+# M5: Enable protection scope filtering in deck builder (completed - Phase 1-3, in progress Phase 4+)
+TAG_PROTECTION_SCOPE = os.getenv('TAG_PROTECTION_SCOPE', '1').lower() not in ('0', 'false', 'off', 'disabled')
\ No newline at end of file
diff --git a/code/tagging/phasing_scope_detection.py b/code/tagging/phasing_scope_detection.py
new file mode 100644
index 0000000..b16a3d8
--- /dev/null
+++ b/code/tagging/phasing_scope_detection.py
@@ -0,0 +1,206 @@
+"""
+Phasing Scope Detection Module
+
+Detects the scope of phasing effects with multiple dimensions:
+- Targeted: Phasing (any targeting effect)
+- Self: Phasing (phases itself out)
+- Your Permanents: Phasing (phases your permanents out)
+- Opponent Permanents: Phasing (phases opponent permanents - removal)
+- Blanket: Phasing (phases all permanents out)
+
+Cards can have multiple scope tags (e.g., Targeted + Your Permanents).
+"""
+
+import re
+from typing import Set
+from code.logging_util import get_logger
+
+logger = get_logger(__name__)
+
+
+def get_phasing_scope_tags(text: str, card_name: str, keywords: str = '') -> Set[str]:
+ """
+ Get all phasing scope metadata tags for a card.
+
+ A card can have multiple scope tags:
+ - "Targeted: Phasing" - Uses targeting
+ - "Self: Phasing" - Phases itself out
+ - "Your Permanents: Phasing" - Phases your permanents
+ - "Opponent Permanents: Phasing" - Phases opponent permanents (removal)
+ - "Blanket: Phasing" - Phases all permanents
+
+ Args:
+ text: Card text
+ card_name: Card name
+ keywords: Card keywords (to check for static "Phasing" ability)
+
+ Returns:
+ Set of metadata tags
+ """
+ if not card_name:
+ return set()
+
+ text_lower = text.lower() if text else ''
+ keywords_lower = keywords.lower() if keywords else ''
+ tags = set()
+
+ # Check for static "Phasing" keyword ability (self-phasing)
+ # Only add Self tag if card doesn't grant phasing to others
+ if 'phasing' in keywords_lower:
+ # Remove reminder text to avoid false positives
+ text_no_reminder = re.sub(r'\([^)]*\)', '', text_lower)
+
+ # Check if card grants phasing to others (has granting language in main text)
+ # Look for patterns like "enchanted creature has", "other X have", "target", etc.
+ grants_to_others = bool(re.search(
+ r'(other|target|each|all|enchanted|equipped|creatures? you control|permanents? you control).*phas',
+ text_no_reminder
+ ))
+
+ # If no granting language, it's just self-phasing
+ if not grants_to_others:
+ tags.add('Self: Phasing')
+ return tags # Early return - static keyword only
+
+ # Check if phasing is mentioned in text (including "has phasing", "gain phasing", etc.)
+ if 'phas' not in text_lower: # Changed from 'phase' to 'phas' to catch "phasing" too
+ return tags
+
+ # Check for targeting (any "target" + phasing)
+ # Targeting detection - must have target AND phase in same sentence/clause
+ targeting_patterns = [
+ r'target\s+(?:\w+\s+)*(?:creature|permanent|artifact|enchantment|nonland\s+permanent)s?(?:[^.]*)?phases?\s+out',
+ r'target\s+player\s+controls[^.]*phases?\s+out',
+ ]
+
+ is_targeted = any(re.search(pattern, text_lower) for pattern in targeting_patterns)
+
+ if is_targeted:
+ tags.add("Targeted: Phasing")
+ logger.debug(f"Card '{card_name}': detected Targeted: Phasing")
+
+ # Check for self-phasing
+ self_patterns = [
+ r'this\s+(?:creature|permanent|artifact|enchantment)\s+phases?\s+out',
+ r'~\s+phases?\s+out',
+ rf'\b{re.escape(card_name.lower())}\s+phases?\s+out',
+ # NEW: Triggered self-phasing (King of the Oathbreakers: "it phases out" as reactive protection)
+ r'whenever.*(?:becomes\s+the\s+target|becomes\s+target).*(?:it|this\s+creature)\s+phases?\s+out',
+ # NEW: Consequent self-phasing (Cyclonus: "connive. Then...phase out")
+ r'(?:then|,)\s+(?:it|this\s+creature)\s+phases?\s+out',
+ # NEW: At end of turn/combat self-phasing
+ r'(?:at\s+(?:the\s+)?end\s+of|after).*(?:it|this\s+creature)\s+phases?\s+out',
+ ]
+
+ if any(re.search(pattern, text_lower) for pattern in self_patterns):
+ tags.add("Self: Phasing")
+ logger.debug(f"Card '{card_name}': detected Self: Phasing")
+
+ # Check for opponent permanent phasing (removal effect)
+ opponent_patterns = [
+ r'target\s+(?:\w+\s+)*(?:creature|permanent)\s+an?\s+opponents?\s+controls?\s+phases?\s+out',
+ ]
+
+ # Check for unqualified targets (can target opponents' stuff)
+ # More flexible to handle various phasing patterns
+ unqualified_target_patterns = [
+ r'(?:up\s+to\s+)?(?:one\s+|x\s+|that\s+many\s+)?(?:other\s+)?(?:another\s+)?target\s+(?:\w+\s+)*(?:creature|permanent|artifact|enchantment|nonland\s+permanent)s?(?:[^.]*)?phases?\s+out',
+ r'target\s+(?:\w+\s+)*(?:creature|permanent|artifact|enchantment|land|nonland\s+permanent)(?:,|\s+and)?\s+(?:then|and)?\s+it\s+phases?\s+out',
+ ]
+
+ has_opponent_specific = any(re.search(pattern, text_lower) for pattern in opponent_patterns)
+ has_unqualified_target = any(re.search(pattern, text_lower) for pattern in unqualified_target_patterns)
+
+ # If unqualified AND not restricted to "you control", can target opponents
+ if has_opponent_specific or (has_unqualified_target and 'you control' not in text_lower):
+ tags.add("Opponent Permanents: Phasing")
+ logger.debug(f"Card '{card_name}': detected Opponent Permanents: Phasing")
+
+ # Check for your permanents phasing
+ your_patterns = [
+ # Explicit "you control"
+ r'(?:target\s+)?(?:creatures?|permanents?|nonland\s+permanents?)\s+you\s+control\s+phases?\s+out',
+ r'(?:target\s+)?(?:other\s+)?(?:creatures?|permanents?)\s+you\s+control\s+phases?\s+out',
+ r'permanents?\s+you\s+control\s+phase\s+out',
+ r'(?:any|up\s+to)\s+(?:number\s+of\s+)?(?:target\s+)?(?:other\s+)?(?:creatures?|permanents?|nonland\s+permanents?)\s+you\s+control\s+phases?\s+out',
+ r'all\s+(?:creatures?|permanents?)\s+you\s+control\s+phase\s+out',
+ r'each\s+(?:creature|permanent)\s+you\s+control\s+phases?\s+out',
+ # Pronoun reference to "you control" context
+ r'(?:creatures?|permanents?|planeswalkers?)\s+you\s+control[^.]*(?:those|the)\s+(?:creatures?|permanents?|planeswalkers?)\s+phase\s+out',
+ r'creature\s+you\s+control[^.]*(?:it)\s+phases?\s+out',
+ # "Those permanents" referring back to controlled permanents (across sentence boundaries)
+ r'you\s+control.*those\s+(?:creatures?|permanents?|planeswalkers?)\s+phase\s+out',
+ # Equipment/Aura (beneficial to your permanents)
+ r'equipped\s+(?:creature|permanent)\s+(?:gets\s+[^.]*\s+and\s+)?phases?\s+out',
+ r'enchanted\s+(?:creature|permanent)\s+(?:gets\s+[^.]*\s+and\s+)?phases?\s+out',
+ r'enchanted\s+(?:creature|permanent)\s+(?:has|gains?)\s+phasing', # NEW: "has phasing" for Cloak of Invisibility, Teferi's Curse
+ # Pronoun reference after equipped/enchanted creature mentioned
+ r'(?:equipped|enchanted)\s+(?:creature|permanent)[^.]*,?\s+(?:then\s+)?that\s+(?:creature|permanent)\s+phases?\s+out',
+ # Target controlled by specific player
+ r'(?:each|target)\s+(?:creature|permanent)\s+target\s+player\s+controls\s+phases?\s+out',
+ ]
+
+ if any(re.search(pattern, text_lower) for pattern in your_patterns):
+ tags.add("Your Permanents: Phasing")
+ logger.debug(f"Card '{card_name}': detected Your Permanents: Phasing")
+
+ # Check for blanket phasing (all permanents, no ownership)
+ blanket_patterns = [
+ r'all\s+(?:nontoken\s+)?(?:creatures?|permanents?)(?:\s+of\s+that\s+type)?\s+(?:[^.]*\s+)?phase\s+out',
+ r'each\s+(?:creature|permanent)\s+(?:[^.]*\s+)?phases?\s+out',
+ # NEW: Type-specific blanket (Shimmer: "Each land of the chosen type has phasing")
+ r'each\s+(?:land|creature|permanent|artifact|enchantment)\s+of\s+the\s+chosen\s+type\s+has\s+phasing',
+ r'(?:lands?|creatures?|permanents?|artifacts?|enchantments?)\s+of\s+the\s+chosen\s+type\s+(?:have|has)\s+phasing',
+ # Pronoun reference to "all creatures"
+ r'all\s+(?:nontoken\s+)?(?:creatures?|permanents?)[^.]*,?\s+(?:then\s+)?(?:those|the)\s+(?:creatures?|permanents?)\s+phase\s+out',
+ ]
+
+ # Only blanket if no specific ownership mentioned
+ has_blanket_pattern = any(re.search(pattern, text_lower) for pattern in blanket_patterns)
+ no_ownership = 'you control' not in text_lower and 'target player controls' not in text_lower and 'opponent' not in text_lower
+
+ if has_blanket_pattern and no_ownership:
+ tags.add("Blanket: Phasing")
+ logger.debug(f"Card '{card_name}': detected Blanket: Phasing")
+
+ return tags
+
+
+def has_phasing(text: str) -> bool:
+ """
+ Quick check if card text contains phasing keywords.
+
+ Args:
+ text: Card text
+
+ Returns:
+ True if phasing keyword found
+ """
+ if not text:
+ return False
+
+ text_lower = text.lower()
+
+ # Check for phasing keywords
+ phasing_keywords = [
+ 'phase out',
+ 'phases out',
+ 'phasing',
+ 'phase in',
+ 'phases in',
+ ]
+
+ return any(keyword in text_lower for keyword in phasing_keywords)
+
+
+def is_removal_phasing(tags: Set[str]) -> bool:
+ """
+ Check if phasing effect acts as removal (targets opponent permanents).
+
+ Args:
+ tags: Set of phasing scope tags
+
+ Returns:
+ True if this is removal-style phasing
+ """
+ return "Opponent Permanents: Phasing" in tags
diff --git a/code/tagging/protection_grant_detection.py b/code/tagging/protection_grant_detection.py
index dca37b4..a88a86b 100644
--- a/code/tagging/protection_grant_detection.py
+++ b/code/tagging/protection_grant_detection.py
@@ -50,18 +50,23 @@ def _init_kindred_patterns():
# Grant verb patterns - cards that give protection to other permanents
# These patterns look for grant verbs that affect OTHER permanents, not self
+# M5: Added phasing support
GRANT_VERB_PATTERNS = [
- r'\bgain[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection)\b',
- r'\bgive[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection)\b',
- r'\bgrant[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection)\b',
- r'\bget[s]?\b.*\+.*\b(hexproof|shroud|indestructible|ward|protection)\b', # "gets +X/+X and has" pattern
+ r'\bgain[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection|phasing)\b',
+ r'\bgive[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection|phasing)\b',
+ r'\bgrant[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection|phasing)\b',
+ r'\bhave\b.*\b(hexproof|shroud|indestructible|ward|protection|phasing)\b', # "have hexproof" static grants
+ r'\bget[s]?\b.*\+.*\b(hexproof|shroud|indestructible|ward|protection|phasing)\b', # "gets +X/+X and has hexproof" direct
+ r'\bget[s]?\b.*\+.*\band\b.*\b(gain[s]?|have)\b.*\b(hexproof|shroud|indestructible|ward|protection|phasing)\b', # "gets +X/+X and gains hexproof"
+ r'\bphases? out\b', # M5: Direct phasing triggers (e.g., "it phases out")
]
# Self-reference patterns that should NOT count as granting
# Reminder text and keyword lines only
+# M5: Added phasing support
SELF_REFERENCE_PATTERNS = [
- r'^\s*(hexproof|shroud|indestructible|ward|protection)', # Start of text (keyword ability)
- r'\([^)]*\b(hexproof|shroud|indestructible|ward|protection)[^)]*\)', # Reminder text in parens
+ r'^\s*(hexproof|shroud|indestructible|ward|protection|phasing)', # Start of text (keyword ability)
+ r'\([^)]*\b(hexproof|shroud|indestructible|ward|protection|phasing)[^)]*\)', # Reminder text in parens
]
# Conditional self-grant patterns - activated/triggered abilities that grant to self
@@ -109,13 +114,22 @@ EXCLUSION_PATTERNS = [
]
# Opponent grant patterns - grants to opponent's permanents (EXCLUDE these)
+# NOTE: "all creatures" and "all permanents" are BLANKET effects (help you too),
+# not opponent grants. Only exclude effects that ONLY help opponents.
OPPONENT_GRANT_PATTERNS = [
r'target opponent',
r'each opponent',
- r'all creatures', # "all creatures" without "you control"
- r'all permanents', # "all permanents" without "you control"
- r'each player',
- r'each creature', # "each creature" without "you control"
+ r'opponents? control', # creatures your opponents control
+ r'opponent.*permanents?.*have', # opponent's permanents have
+]
+
+# Blanket grant patterns - affects all permanents regardless of controller
+# These are VALID protection grants that should be tagged (Blanket scope in M5)
+BLANKET_GRANT_PATTERNS = [
+ r'\ball creatures? (have|gain|get)\b', # All creatures gain hexproof
+ r'\ball permanents? (have|gain|get)\b', # All permanents gain indestructible
+ r'\beach creature (has|gains?|gets?)\b', # Each creature gains ward
+ r'\beach player\b', # Each player gains hexproof (very rare but valid blanket)
]
# Kindred-specific grant patterns for metadata tagging
@@ -179,9 +193,16 @@ def get_kindred_protection_tags(text: str) -> Set[str]:
"""
Identify kindred-specific protection grants for metadata tagging.
- Returns a set of metadata tag names like "Knights Gain Protection".
+ Returns a set of metadata tag names like:
+ - "Knights Gain Hexproof"
+ - "Spiders Gain Ward"
+ - "Artifacts Gain Indestructible"
- Uses both predefined patterns and dynamic creature type detection.
+ Uses both predefined patterns and dynamic creature type detection,
+ with specific ability detection (hexproof, ward, indestructible, shroud, protection).
+
+ IMPORTANT: Only tags the specific abilities that appear in the same sentence
+ as the creature type grant to avoid false positives like Svyelun.
"""
if not text:
return set()
@@ -192,21 +213,52 @@ def get_kindred_protection_tags(text: str) -> Set[str]:
text_lower = text.lower()
tags = set()
- # Check predefined patterns (specific kindred types we track)
- for tag_name, patterns in KINDRED_GRANT_PATTERNS.items():
- for pattern in patterns:
- if re.search(pattern, text_lower, re.IGNORECASE):
- tags.add(tag_name)
- break # Found match for this kindred type, move to next
-
- # Only check dynamic patterns if protection keywords present (performance optimization)
- if not any(keyword in text_lower for keyword in ['hexproof', 'shroud', 'indestructible', 'ward', 'protection']):
+ # Only proceed if protective abilities are present (performance optimization)
+ protective_abilities = ['hexproof', 'shroud', 'indestructible', 'ward', 'protection']
+ if not any(keyword in text_lower for keyword in protective_abilities):
return tags
+ # Check predefined patterns (specific kindred types we track)
+ for tag_base, patterns in KINDRED_GRANT_PATTERNS.items():
+ for pattern in patterns:
+ match = re.search(pattern, text_lower, re.IGNORECASE)
+ if match:
+ # Extract creature type from tag_base (e.g., "Knights" from "Knights Gain Protection")
+ creature_type = tag_base.split(' Gain ')[0]
+ # Get the matched text to check which abilities are in this specific grant
+ matched_text = match.group(0)
+ # Only tag abilities that appear in the matched phrase
+ if 'hexproof' in matched_text:
+ tags.add(f"{creature_type} Gain Hexproof")
+ if 'shroud' in matched_text:
+ tags.add(f"{creature_type} Gain Shroud")
+ if 'indestructible' in matched_text:
+ tags.add(f"{creature_type} Gain Indestructible")
+ if 'ward' in matched_text:
+ tags.add(f"{creature_type} Gain Ward")
+ if 'protection' in matched_text:
+ tags.add(f"{creature_type} Gain Protection")
+ break # Found match for this kindred type, move to next
+
# Use pre-compiled patterns for all creature types
- for compiled_pattern, tag_name in KINDRED_PATTERNS:
- if compiled_pattern.search(text_lower):
- tags.add(tag_name)
+ for compiled_pattern, tag_template in KINDRED_PATTERNS:
+ match = compiled_pattern.search(text_lower)
+ if match:
+ # Extract creature type from tag_template (e.g., "Knights" from "Knights Gain Protection")
+ creature_type = tag_template.split(' Gain ')[0]
+ # Get the matched text to check which abilities are in this specific grant
+ matched_text = match.group(0)
+ # Only tag abilities that appear in the matched phrase
+ if 'hexproof' in matched_text:
+ tags.add(f"{creature_type} Gain Hexproof")
+ if 'shroud' in matched_text:
+ tags.add(f"{creature_type} Gain Shroud")
+ if 'indestructible' in matched_text:
+ tags.add(f"{creature_type} Gain Indestructible")
+ if 'ward' in matched_text:
+ tags.add(f"{creature_type} Gain Ward")
+ if 'protection' in matched_text:
+ tags.add(f"{creature_type} Gain Protection")
# Don't break - a card could grant to multiple creature types
return tags
@@ -214,23 +266,33 @@ def get_kindred_protection_tags(text: str) -> Set[str]:
def is_opponent_grant(text: str) -> bool:
"""
- Check if card grants protection to opponent's permanents or all permanents.
+ Check if card grants protection to opponent's permanents ONLY.
- Returns True if this grants to opponents (should be excluded from Protection tag).
+ Returns True if this grants ONLY to opponents (should be excluded from Protection tag).
+ Does NOT exclude blanket effects like "all creatures gain hexproof" which help you too.
"""
if not text:
return False
text_lower = text.lower()
- # Check for opponent grant patterns
+ # Remove reminder text (in parentheses) to avoid false positives
+ # Reminder text often mentions "opponents control" for hexproof/shroud explanations
+ text_no_reminder = re.sub(r'\([^)]*\)', '', text_lower)
+
+ # Check for opponent-specific grant patterns in the main text (not reminder)
for pattern in OPPONENT_GRANT_PATTERNS:
- if re.search(pattern, text_lower, re.IGNORECASE):
- # Make sure it's not "target opponent" for a different effect
+ match = re.search(pattern, text_no_reminder, re.IGNORECASE)
+ if match:
# Must be in context of granting protection
if any(prot in text_lower for prot in ['hexproof', 'shroud', 'indestructible', 'ward', 'protection']):
- # Check if "you control" appears in same sentence
- if 'you control' not in text_lower.split('.')[0]:
+ # Check the context around the match
+ context_start = max(0, match.start() - 30)
+ context_end = min(len(text_no_reminder), match.end() + 70)
+ context = text_no_reminder[context_start:context_end]
+
+ # If "you control" appears in the context, it's limiting to YOUR permanents, not opponents
+ if 'you control' not in context:
return True
return False
@@ -372,12 +434,11 @@ def is_granting_protection(text: str, keywords: str, exclude_kindred: bool = Fal
# Check for explicit grants with protection keywords
found_grant = False
- # Mass grant patterns (creatures you control have/gain)
- for pattern in MASS_GRANT_PATTERNS:
+ # Blanket grant patterns (all creatures gain hexproof) - these are VALID grants
+ for pattern in BLANKET_GRANT_PATTERNS:
match = re.search(pattern, text_lower, re.IGNORECASE)
if match:
- # Check if protection keyword appears in the same sentence or nearby (within 70 chars AFTER the match)
- # This ensures we're looking at "creatures you control HAVE hexproof" not just having both phrases
+ # Check if protection keyword appears nearby
context_start = match.start()
context_end = min(len(text_lower), match.end() + 70)
context = text_lower[context_start:context_end]
@@ -386,6 +447,21 @@ def is_granting_protection(text: str, keywords: str, exclude_kindred: bool = Fal
found_grant = True
break
+ # Mass grant patterns (creatures you control have/gain)
+ if not found_grant:
+ for pattern in MASS_GRANT_PATTERNS:
+ match = re.search(pattern, text_lower, re.IGNORECASE)
+ if match:
+ # Check if protection keyword appears in the same sentence or nearby (within 70 chars AFTER the match)
+ # This ensures we're looking at "creatures you control HAVE hexproof" not just having both phrases
+ context_start = match.start()
+ context_end = min(len(text_lower), match.end() + 70)
+ context = text_lower[context_start:context_end]
+
+ if any(prot in context for prot in PROTECTION_KEYWORDS):
+ found_grant = True
+ break
+
# Targeted grant patterns (target creature gains)
if not found_grant:
for pattern in TARGETED_GRANT_PATTERNS:
diff --git a/code/tagging/protection_scope_detection.py b/code/tagging/protection_scope_detection.py
new file mode 100644
index 0000000..bffc768
--- /dev/null
+++ b/code/tagging/protection_scope_detection.py
@@ -0,0 +1,206 @@
+"""
+Protection Scope Detection Module
+
+Detects the scope of protection effects (Self, Your Permanents, Blanket, Opponent Permanents)
+to enable intelligent filtering in deck building.
+
+Part of M5: Protection Effect Granularity milestone.
+"""
+
+import re
+from typing import Optional, Set
+from code.logging_util import get_logger
+
+logger = get_logger(__name__)
+
+
+# Protection abilities to detect
+PROTECTION_ABILITIES = [
+ 'Protection',
+ 'Ward',
+ 'Hexproof',
+ 'Shroud',
+ 'Indestructible'
+]
+
+
+def detect_protection_scope(text: str, card_name: str, ability: str) -> Optional[str]:
+ """
+ Detect the scope of a protection effect.
+
+ Detection priority order (prevents misclassification):
+ 1. Opponent ownership → "Opponent Permanents"
+ 2. Your ownership → "Your Permanents"
+ 3. Self-reference → "Self"
+ 4. No ownership qualifier → "Blanket"
+
+ Args:
+ text: Card text (lowercase for pattern matching)
+ card_name: Card name (for self-reference detection)
+ ability: Ability type (Ward, Hexproof, etc.)
+
+ Returns:
+ Scope prefix or None: "Self", "Your Permanents", "Blanket", "Opponent Permanents"
+ """
+ if not text or not ability:
+ return None
+
+ text_lower = text.lower()
+ ability_lower = ability.lower()
+ card_name_lower = card_name.lower()
+
+ # Check if ability is mentioned in text
+ if ability_lower not in text_lower:
+ return None
+
+ # Priority 1: Opponent ownership (grants protection TO opponent's permanents)
+ # Note: Must distinguish from hexproof reminder text "opponents control [spells/abilities]"
+ # Only match when "opponents control" refers to creatures/permanents, not spells
+ opponent_patterns = [
+ r'creatures?\s+(?:your\s+)?opponents?\s+control\s+(?:have|gain)',
+ r'permanents?\s+(?:your\s+)?opponents?\s+control\s+(?:have|gain)',
+ r'each\s+creature\s+an?\s+opponent\s+controls?\s+(?:has|gains?)'
+ ]
+
+ for pattern in opponent_patterns:
+ if re.search(pattern, text_lower):
+ return "Opponent Permanents"
+
+ # Priority 2: Check for self-reference BEFORE "Your Permanents"
+ # This prevents tilde (~) from being caught by creature type patterns
+
+ # Check for tilde (~) - strong self-reference indicator
+ tilde_patterns = [
+ r'~\s+(?:has|gains?)\s+' + ability_lower,
+ r'~\s+is\s+' + ability_lower
+ ]
+
+ for pattern in tilde_patterns:
+ if re.search(pattern, text_lower):
+ return "Self"
+
+ # Check for "this creature/permanent" pronouns
+ this_patterns = [
+ r'this\s+(?:creature|permanent|artifact|enchantment)\s+(?:has|gains?)\s+' + ability_lower,
+ r'^(?:has|gains?)\s+' + ability_lower # Starts with ability (likely self)
+ ]
+
+ for pattern in this_patterns:
+ if re.search(pattern, text_lower):
+ return "Self"
+
+ # Check for card name (replace special characters for matching)
+ card_name_escaped = re.escape(card_name_lower)
+ if re.search(rf'\b{card_name_escaped}\b', text_lower):
+ # Make sure it's in a self-protection context
+ # e.g., "Svyelun has indestructible" not "Svyelun and other Merfolk"
+ self_context_patterns = [
+ rf'\b{card_name_escaped}\s+(?:has|gains?)\s+{ability_lower}',
+ rf'\b{card_name_escaped}\s+is\s+{ability_lower}'
+ ]
+ for pattern in self_context_patterns:
+ if re.search(pattern, text_lower):
+ return "Self"
+
+ # NEW: If no grant patterns found at all, assume inherent protection (Self)
+ # This catches cards where protection is in the keywords field but not explained in text
+ # e.g., "Protection from creatures" as a keyword line
+ # Check if we have the ability keyword but no grant patterns
+ has_grant_pattern = any(re.search(pattern, text_lower) for pattern in [
+ r'(?:have|gain|grant|give|get)[s]?\s+',
+ r'other\s+',
+ r'creatures?\s+you\s+control',
+ r'permanents?\s+you\s+control',
+ r'equipped',
+ r'enchanted',
+ r'target'
+ ])
+
+ if not has_grant_pattern:
+ # No grant verbs found - likely inherent protection
+ return "Self"
+
+ # Priority 3: Your ownership (most common)
+ # Note: "Other [Type]" patterns included for type-specific grants
+ # Note: "equipped creature", "target creature", etc. are permanents you control
+ your_patterns = [
+ r'(?:other\s+)?(?:creatures?|permanents?|artifacts?|enchantments?)\s+you\s+control',
+ r'your\s+(?:creatures?|permanents?|artifacts?|enchantments?)',
+ r'each\s+(?:creature|permanent)\s+you\s+control',
+ r'other\s+\w+s?\s+you\s+control', # "Other Merfolk you control", etc.
+ # NEW: "Other X you control...have Y" pattern for static grants
+ r'other\s+(?:\w+\s+)?(?:creatures?|permanents?)\s+you\s+control\s+(?:get\s+[^.]*\s+and\s+)?have\s+' + ability_lower,
+ r'other\s+\w+s?\s+you\s+control\s+(?:get\s+[^.]*\s+and\s+)?have\s+' + ability_lower, # "Other Knights you control...have"
+ r'equipped\s+(?:creature|permanent)\s+(?:gets\s+[^.]*\s+and\s+)?(?:has|gains?)\s+(?:[^.]*\s+and\s+)?' + ability_lower, # Equipment
+ r'enchanted\s+(?:creature|permanent)\s+(?:gets\s+[^.]*\s+and\s+)?(?:has|gains?)\s+(?:[^.]*\s+and\s+)?' + ability_lower, # Aura
+ r'target\s+(?:\w+\s+)?(?:creature|permanent)\s+(?:gets\s+[^.]*\s+and\s+)?(?:gains?)\s+' + ability_lower # Target (with optional adjective)
+ ]
+
+ for pattern in your_patterns:
+ if re.search(pattern, text_lower):
+ return "Your Permanents"
+
+ # Priority 4: Blanket (no ownership qualifier)
+ # Only apply if we have protection keyword but no ownership context
+ # Note: Abilities can be listed with "and" (e.g., "gain hexproof and indestructible")
+ blanket_patterns = [
+ r'all\s+(?:creatures?|permanents?)\s+(?:have|gain)\s+(?:[^.]*\s+and\s+)?' + ability_lower,
+ r'each\s+(?:creature|permanent)\s+(?:has|gains?)\s+(?:[^.]*\s+and\s+)?' + ability_lower,
+ r'(?:creatures?|permanents?)\s+(?:have|gain)\s+(?:[^.]*\s+and\s+)?' + ability_lower
+ ]
+
+ for pattern in blanket_patterns:
+ if re.search(pattern, text_lower):
+ # Double-check no ownership was missed
+ if 'you control' not in text_lower and 'opponent' not in text_lower:
+ return "Blanket"
+
+ return None
+
+
+def get_protection_scope_tags(text: str, card_name: str) -> Set[str]:
+ """
+ Get all protection scope metadata tags for a card.
+
+ A card can have multiple protection scopes (e.g., self-hexproof + grants ward to others).
+
+ Args:
+ text: Card text
+ card_name: Card name
+
+ Returns:
+ Set of metadata tags like {"Self: Indestructible", "Your Permanents: Ward"}
+ """
+ if not text or not card_name:
+ return set()
+
+ scope_tags = set()
+
+ # Check each protection ability
+ for ability in PROTECTION_ABILITIES:
+ scope = detect_protection_scope(text, card_name, ability)
+
+ if scope:
+ # Format: "{Scope}: {Ability}"
+ tag = f"{scope}: {ability}"
+ scope_tags.add(tag)
+ logger.debug(f"Card '{card_name}': detected scope tag '{tag}'")
+
+ return scope_tags
+
+
+def has_any_protection(text: str) -> bool:
+ """
+ Quick check if card text contains any protection keywords.
+
+ Args:
+ text: Card text
+
+ Returns:
+ True if any protection keyword found
+ """
+ if not text:
+ return False
+
+ text_lower = text.lower()
+ return any(ability.lower() in text_lower for ability in PROTECTION_ABILITIES)
diff --git a/code/tagging/tag_constants.py b/code/tagging/tag_constants.py
index 6e5f3c4..e3d8895 100644
--- a/code/tagging/tag_constants.py
+++ b/code/tagging/tag_constants.py
@@ -927,11 +927,32 @@ KEYWORD_ALLOWLIST: set[str] = {
'Tempting offer', 'Will of the council', 'Parley', 'Adamant', 'Devotion',
}
-# Metadata tag prefixes (for M3 - metadata partition)
-# Tags matching these patterns should be classified as metadata, not themes
+# ==============================================================================
+# Metadata Tag Classification (M3 - Tagging Refinement)
+# ==============================================================================
+
+# Metadata tag prefixes - tags starting with these are classified as metadata
METADATA_TAG_PREFIXES: List[str] = [
'Applied:',
'Bracket:',
'Diagnostic:',
'Internal:',
-]
\ No newline at end of file
+]
+
+# Specific metadata tags (full match) - additional tags to classify as metadata
+# These are typically diagnostic, bracket-related, or internal annotations
+METADATA_TAG_ALLOWLIST: set[str] = {
+ # Bracket annotations
+ 'Bracket: Game Changer',
+ 'Bracket: Staple',
+ 'Bracket: Format Warping',
+
+ # Cost reduction diagnostics (from Applied: namespace)
+ 'Applied: Cost Reduction',
+
+ # Kindred-specific protection metadata (from M2)
+ # Format: "{CreatureType}s Gain Protection"
+ # These are auto-generated for kindred-specific protection grants
+ # Example: "Knights Gain Protection", "Frogs Gain Protection"
+ # Note: These are dynamically generated, so we match via prefix in classify_tag
+}
\ No newline at end of file
diff --git a/code/tagging/tag_utils.py b/code/tagging/tag_utils.py
index e731f07..eb58aa6 100644
--- a/code/tagging/tag_utils.py
+++ b/code/tagging/tag_utils.py
@@ -582,4 +582,80 @@ def normalize_keywords(
normalized_keywords.add(normalized)
- return sorted(list(normalized_keywords))
\ No newline at end of file
+ return sorted(list(normalized_keywords))
+
+
+# ==============================================================================
+# M3: Metadata vs Theme Tag Classification
+# ==============================================================================
+
+def classify_tag(tag: str) -> str:
+ """Classify a tag as either 'metadata' or 'theme'.
+
+ Metadata tags are diagnostic, bracket-related, or internal annotations that
+ should not appear in theme catalogs or player-facing tag lists. Theme tags
+ represent gameplay mechanics and deck archetypes.
+
+ Classification rules (in order of precedence):
+ 1. Prefix match: Tags starting with METADATA_TAG_PREFIXES → metadata
+ 2. Exact match: Tags in METADATA_TAG_ALLOWLIST → metadata
+ 3. Kindred pattern: "{Type}s Gain Protection" → metadata
+ 4. Default: All other tags → theme
+
+ Args:
+ tag: Tag string to classify
+
+ Returns:
+ "metadata" or "theme"
+
+ Examples:
+ >>> classify_tag("Applied: Cost Reduction")
+ 'metadata'
+ >>> classify_tag("Bracket: Game Changer")
+ 'metadata'
+ >>> classify_tag("Knights Gain Protection")
+ 'metadata'
+ >>> classify_tag("Card Draw")
+ 'theme'
+ >>> classify_tag("Spellslinger")
+ 'theme'
+ """
+ # Prefix-based classification
+ for prefix in tag_constants.METADATA_TAG_PREFIXES:
+ if tag.startswith(prefix):
+ return "metadata"
+
+ # Exact match classification
+ if tag in tag_constants.METADATA_TAG_ALLOWLIST:
+ return "metadata"
+
+ # Kindred protection metadata patterns: "{Type} Gain {Ability}"
+ # Covers all protective abilities: Protection, Ward, Hexproof, Shroud, Indestructible
+ # Examples: "Knights Gain Protection", "Spiders Gain Ward", "Merfolk Gain Ward"
+ # Note: Checks for " Gain " pattern since some creature types like "Merfolk" don't end in 's'
+ kindred_abilities = ["Protection", "Ward", "Hexproof", "Shroud", "Indestructible"]
+ for ability in kindred_abilities:
+ if " Gain " in tag and tag.endswith(ability):
+ return "metadata"
+
+ # Protection scope metadata patterns (M5): "{Scope}: {Ability}"
+ # Indicates whether protection applies to self, your permanents, all permanents, or opponent's permanents
+ # Examples: "Self: Hexproof", "Your Permanents: Ward", "Blanket: Indestructible"
+ # These enable deck builder to filter for board-relevant protection vs self-only
+ protection_scopes = ["Self:", "Your Permanents:", "Blanket:", "Opponent Permanents:"]
+ for scope in protection_scopes:
+ if tag.startswith(scope):
+ return "metadata"
+
+ # Phasing scope metadata patterns: "{Scope}: Phasing"
+ # Indicates whether phasing applies to self, your permanents, all permanents, or opponents
+ # Examples: "Self: Phasing", "Your Permanents: Phasing", "Blanket: Phasing",
+ # "Targeted: Phasing", "Opponent Permanents: Phasing"
+ # Similar to protection scopes, enables filtering for board-relevant phasing
+ # Opponent Permanents: Phasing also triggers Removal tag (removal-style phasing)
+ if tag in ["Self: Phasing", "Your Permanents: Phasing", "Blanket: Phasing",
+ "Targeted: Phasing", "Opponent Permanents: Phasing"]:
+ return "metadata"
+
+ # Default: treat as theme tag
+ return "theme"
\ No newline at end of file
diff --git a/code/tagging/tagger.py b/code/tagging/tagger.py
index b2b3f0b..94ef6da 100644
--- a/code/tagging/tagger.py
+++ b/code/tagging/tagger.py
@@ -159,6 +159,134 @@ def _write_compat_snapshot(df: pd.DataFrame, color: str) -> None:
except Exception as exc:
logger.warning("Failed to write unmerged snapshot for %s: %s", color, exc)
+
+def _apply_metadata_partition(df: pd.DataFrame) -> tuple[pd.DataFrame, Dict[str, Any]]:
+ """Partition tags into themeTags and metadataTags columns.
+
+ Metadata tags are diagnostic, bracket-related, or internal annotations that
+ should not appear in theme catalogs or player-facing lists. This function:
+ 1. Creates a new 'metadataTags' column
+ 2. Classifies each tag in 'themeTags' as metadata or theme
+ 3. Moves metadata tags to 'metadataTags' column
+ 4. Keeps theme tags in 'themeTags' column
+ 5. Returns summary diagnostics
+
+ Args:
+ df: DataFrame with 'themeTags' column (list of tag strings)
+
+ Returns:
+ Tuple of (modified DataFrame, diagnostics dict)
+
+ Diagnostics dict contains:
+ - total_rows: number of rows processed
+ - rows_with_tags: rows that had any tags
+ - metadata_tags_moved: total count of metadata tags moved
+ - theme_tags_kept: total count of theme tags kept
+ - tag_distribution: dict mapping tag -> classification
+ - most_common_metadata: list of (tag, count) tuples
+ - most_common_themes: list of (tag, count) tuples
+
+ Example:
+ >>> df = pd.DataFrame({'themeTags': [['Card Draw', 'Applied: Cost Reduction']]})
+ >>> df_out, diag = _apply_metadata_partition(df)
+ >>> df_out['themeTags'].iloc[0]
+ ['Card Draw']
+ >>> df_out['metadataTags'].iloc[0]
+ ['Applied: Cost Reduction']
+ >>> diag['metadata_tags_moved']
+ 1
+ """
+ # Check feature flag directly from environment (not from settings module)
+ # This allows tests to monkeypatch the environment variable
+ tag_metadata_split = os.getenv('TAG_METADATA_SPLIT', '1').lower() not in ('0', 'false', 'off', 'disabled')
+
+ # Feature flag check - return unmodified if disabled
+ if not tag_metadata_split:
+ logger.info("TAG_METADATA_SPLIT disabled, skipping metadata partition")
+ return df, {
+ "enabled": False,
+ "total_rows": len(df),
+ "message": "Feature disabled via TAG_METADATA_SPLIT=0"
+ }
+
+ # Validate input
+ if 'themeTags' not in df.columns:
+ logger.warning("No 'themeTags' column found, skipping metadata partition")
+ return df, {
+ "enabled": True,
+ "error": "Missing themeTags column",
+ "total_rows": len(df)
+ }
+
+ # Initialize metadataTags column
+ df['metadataTags'] = pd.Series([[] for _ in range(len(df))], index=df.index)
+
+ # Track statistics
+ metadata_counts: Dict[str, int] = {}
+ theme_counts: Dict[str, int] = {}
+ total_metadata_moved = 0
+ total_theme_kept = 0
+ rows_with_tags = 0
+
+ # Process each row
+ for idx in df.index:
+ tags = df.at[idx, 'themeTags']
+
+ # Skip if not a list or empty
+ if not isinstance(tags, list) or not tags:
+ continue
+
+ rows_with_tags += 1
+
+ # Classify each tag
+ metadata_tags = []
+ theme_tags = []
+
+ for tag in tags:
+ classification = tag_utils.classify_tag(tag)
+
+ if classification == "metadata":
+ metadata_tags.append(tag)
+ metadata_counts[tag] = metadata_counts.get(tag, 0) + 1
+ total_metadata_moved += 1
+ else:
+ theme_tags.append(tag)
+ theme_counts[tag] = theme_counts.get(tag, 0) + 1
+ total_theme_kept += 1
+
+ # Update columns
+ df.at[idx, 'themeTags'] = theme_tags
+ df.at[idx, 'metadataTags'] = metadata_tags
+
+ # Sort tag lists for top N reporting
+ most_common_metadata = sorted(metadata_counts.items(), key=lambda x: x[1], reverse=True)[:10]
+ most_common_themes = sorted(theme_counts.items(), key=lambda x: x[1], reverse=True)[:10]
+
+ # Build diagnostics
+ diagnostics = {
+ "enabled": True,
+ "total_rows": len(df),
+ "rows_with_tags": rows_with_tags,
+ "metadata_tags_moved": total_metadata_moved,
+ "theme_tags_kept": total_theme_kept,
+ "unique_metadata_tags": len(metadata_counts),
+ "unique_theme_tags": len(theme_counts),
+ "most_common_metadata": most_common_metadata,
+ "most_common_themes": most_common_themes
+ }
+
+ # Log summary
+ logger.info(
+ f"Metadata partition complete: {total_metadata_moved} metadata tags moved, "
+ f"{total_theme_kept} theme tags kept across {rows_with_tags} rows"
+ )
+
+ if most_common_metadata:
+ top_5_metadata = ', '.join([f"{tag}({ct})" for tag, ct in most_common_metadata[:5]])
+ logger.info(f"Top metadata tags: {top_5_metadata}")
+
+ return df, diagnostics
+
### Setup
## Load the dataframe
def load_dataframe(color: str) -> None:
@@ -211,7 +339,14 @@ def load_dataframe(color: str) -> None:
raise ValueError(f"Failed to add required columns: {still_missing}")
# Load final dataframe with proper converters
- df = pd.read_csv(filepath, converters={'themeTags': pd.eval, 'creatureTypes': pd.eval})
+ # M3: metadataTags is optional (may not exist in older CSVs)
+ converters = {'themeTags': pd.eval, 'creatureTypes': pd.eval}
+
+ # Add metadataTags converter if column exists
+ if 'metadataTags' in check_df.columns:
+ converters['metadataTags'] = pd.eval
+
+ df = pd.read_csv(filepath, converters=converters)
# Process the dataframe
tag_by_color(df, color)
@@ -331,8 +466,15 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None:
if color == 'commander':
df = enrich_commander_rows_with_tags(df, CSV_DIRECTORY)
- # Lastly, sort all theme tags for easier reading and reorder columns
+ # Sort all theme tags for easier reading and reorder columns
df = sort_theme_tags(df, color)
+
+ # M3: Partition metadata tags from theme tags
+ df, partition_diagnostics = _apply_metadata_partition(df)
+ if partition_diagnostics.get("enabled"):
+ logger.info(f"Metadata partition for {color}: {partition_diagnostics['metadata_tags_moved']} metadata, "
+ f"{partition_diagnostics['theme_tags_kept']} theme tags")
+
df.to_csv(f'{CSV_DIRECTORY}/{color}_cards.csv', index=False)
#print(df)
print('\n====================\n')
@@ -6652,6 +6794,11 @@ def tag_for_interaction(df: pd.DataFrame, color: str) -> None:
logger.info(f'Completed protection tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s')
print('\n==========\n')
+ sub_start = pd.Timestamp.now()
+ tag_for_phasing(df, color)
+ logger.info(f'Completed phasing tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s')
+ print('\n==========\n')
+
sub_start = pd.Timestamp.now()
tag_for_removal(df, color)
logger.info(f'Completed removal tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s')
@@ -7076,24 +7223,59 @@ def tag_for_protection(df: pd.DataFrame, color: str) -> None:
)
final_mask = grant_mask
- logger.info(f'Using M2 grant detection (TAG_PROTECTION_GRANTS=1)')
+ logger.info('Using M2 grant detection (TAG_PROTECTION_GRANTS=1)')
# Apply kindred metadata tags for creature-type-specific grants
+ # Note: These are added to themeTags first, then _apply_metadata_partition()
+ # will classify them as metadata and move them to metadataTags column
kindred_count = 0
for idx, row in df[final_mask].iterrows():
text = str(row.get('text', ''))
kindred_tags = get_kindred_protection_tags(text)
if kindred_tags:
- # Add kindred-specific metadata tags
- current_tags = str(row.get('metadataTags', ''))
- existing = set(t.strip() for t in current_tags.split(',') if t.strip())
- existing.update(kindred_tags)
- df.at[idx, 'metadataTags'] = ', '.join(sorted(existing))
+ # Add to themeTags temporarily - partition will move to metadataTags
+ current_tags = row.get('themeTags', [])
+ if not isinstance(current_tags, list):
+ current_tags = []
+
+ # Add kindred tags (they'll be classified as metadata later)
+ updated_tags = list(set(current_tags) | set(kindred_tags))
+ df.at[idx, 'themeTags'] = updated_tags
kindred_count += 1
if kindred_count > 0:
- logger.info(f'Applied kindred metadata tags to {kindred_count} cards')
+ logger.info(f'Applied kindred protection tags to {kindred_count} cards (will be moved to metadata by partition)')
+
+ # M5: Add protection scope metadata tags (Self, Your Permanents, Blanket, Opponent)
+ # Apply to ALL cards with protection effects, not just those that passed grant filter
+ # This ensures inherent protection cards like Aysen Highway get "Self: Protection" tags
+ from code.tagging.protection_scope_detection import get_protection_scope_tags, has_any_protection
+
+ scope_count = 0
+ for idx, row in df.iterrows():
+ text = str(row.get('text', ''))
+ name = str(row.get('name', ''))
+ keywords = str(row.get('keywords', ''))
+
+ # Check if card has ANY protection effects (text or keywords)
+ if not has_any_protection(text) and not any(k in keywords.lower() for k in ['hexproof', 'shroud', 'indestructible', 'ward', 'protection', 'phasing']):
+ continue
+
+ scope_tags = get_protection_scope_tags(text, name)
+
+ if scope_tags:
+ current_tags = row.get('themeTags', [])
+ if not isinstance(current_tags, list):
+ current_tags = []
+
+ # Add scope tags to themeTags (partition will move to metadataTags)
+ updated_tags = list(set(current_tags) | set(scope_tags))
+ df.at[idx, 'themeTags'] = updated_tags
+ scope_count += 1
+
+ if scope_count > 0:
+ logger.info(f'Applied protection scope tags to {scope_count} cards (will be moved to metadata by partition)')
else:
# Legacy: Use original text/keyword patterns
text_mask = create_protection_text_mask(df)
@@ -7101,13 +7283,50 @@ def tag_for_protection(df: pd.DataFrame, color: str) -> None:
exclusion_mask = create_protection_exclusion_mask(df)
final_mask = (text_mask | keyword_mask) & ~exclusion_mask
- # Apply tags via rules engine
+ # Apply generic protection tags first
tag_utils.apply_rules(df, rules=[
{
'mask': final_mask,
'tags': ['Protection', 'Interaction']
}
])
+
+ # Apply specific protection ability tags (Hexproof, Indestructible, etc.)
+ # These are theme tags indicating which specific protections the card provides
+ ability_tag_count = 0
+ for idx, row in df[final_mask].iterrows():
+ text = str(row.get('text', ''))
+ keywords = str(row.get('keywords', ''))
+
+ # Detect which specific abilities are present
+ ability_tags = set()
+ text_lower = text.lower()
+ keywords_lower = keywords.lower()
+
+ # Check for each protection ability
+ if 'hexproof' in text_lower or 'hexproof' in keywords_lower:
+ ability_tags.add('Hexproof')
+ if 'indestructible' in text_lower or 'indestructible' in keywords_lower:
+ ability_tags.add('Indestructible')
+ if 'shroud' in text_lower or 'shroud' in keywords_lower:
+ ability_tags.add('Shroud')
+ if 'ward' in text_lower or 'ward' in keywords_lower:
+ ability_tags.add('Ward')
+ if 'protection from' in text_lower or 'protection from' in keywords_lower:
+ ability_tags.add('Protection from Color')
+
+ if ability_tags:
+ current_tags = row.get('themeTags', [])
+ if not isinstance(current_tags, list):
+ current_tags = []
+
+ # Add ability tags to themeTags
+ updated_tags = list(set(current_tags) | ability_tags)
+ df.at[idx, 'themeTags'] = updated_tags
+ ability_tag_count += 1
+
+ if ability_tag_count > 0:
+ logger.info(f'Applied specific protection ability tags to {ability_tag_count} cards')
# Log results
duration = (pd.Timestamp.now() - start_time).total_seconds()
@@ -7117,6 +7336,101 @@ def tag_for_protection(df: pd.DataFrame, color: str) -> None:
logger.error(f'Error in tag_for_protection: {str(e)}')
raise
+## Phasing effects
+def tag_for_phasing(df: pd.DataFrame, color: str) -> None:
+ """Tag cards that provide phasing effects using vectorized operations.
+
+ This function identifies and tags cards with phasing effects including:
+ - Cards that phase permanents out
+ - Cards with phasing keyword
+
+ Similar to M5 protection tagging, adds scope metadata tags:
+ - Self: Phasing (card phases itself out)
+ - Your Permanents: Phasing (phases your permanents out)
+ - Blanket: Phasing (phases all permanents out)
+
+ Args:
+ df: DataFrame containing card data
+ color: Color identifier for logging purposes
+
+ Raises:
+ ValueError: If required DataFrame columns are missing
+ TypeError: If inputs are not of correct type
+ """
+ start_time = pd.Timestamp.now()
+ logger.info(f'Starting phasing effect tagging for {color}_cards.csv')
+
+ try:
+ # Validate inputs
+ if not isinstance(df, pd.DataFrame):
+ raise TypeError("df must be a pandas DataFrame")
+ if not isinstance(color, str):
+ raise TypeError("color must be a string")
+
+ # Validate required columns
+ required_cols = {'text', 'themeTags', 'keywords'}
+ tag_utils.validate_dataframe_columns(df, required_cols)
+
+ # Create mask for cards with phasing
+ from code.tagging.phasing_scope_detection import has_phasing, get_phasing_scope_tags, is_removal_phasing
+
+ phasing_mask = df.apply(
+ lambda row: has_phasing(str(row.get('text', ''))) or
+ 'phasing' in str(row.get('keywords', '')).lower(),
+ axis=1
+ )
+
+ # Apply generic "Phasing" theme tag first
+ tag_utils.apply_rules(df, rules=[
+ {
+ 'mask': phasing_mask,
+ 'tags': ['Phasing', 'Interaction']
+ }
+ ])
+
+ # Add phasing scope metadata tags and removal tags
+ scope_count = 0
+ removal_count = 0
+ for idx, row in df[phasing_mask].iterrows():
+ text = str(row.get('text', ''))
+ name = str(row.get('name', ''))
+ keywords = str(row.get('keywords', ''))
+
+ # Check if card has phasing (in text or keywords)
+ if not has_phasing(text) and 'phasing' not in keywords.lower():
+ continue
+
+ scope_tags = get_phasing_scope_tags(text, name, keywords)
+
+ if scope_tags:
+ current_tags = row.get('themeTags', [])
+ if not isinstance(current_tags, list):
+ current_tags = []
+
+ # Add scope tags to themeTags (partition will move to metadataTags)
+ updated_tags = list(set(current_tags) | scope_tags)
+
+ # If this is removal-style phasing, add Removal tag
+ if is_removal_phasing(scope_tags):
+ updated_tags.append('Removal')
+ removal_count += 1
+
+ df.at[idx, 'themeTags'] = updated_tags
+ scope_count += 1
+
+ if scope_count > 0:
+ logger.info(f'Applied phasing scope tags to {scope_count} cards (will be moved to metadata by partition)')
+ if removal_count > 0:
+ logger.info(f'Applied Removal tag to {removal_count} cards with opponent-targeting phasing')
+
+ # Log results
+ duration = (pd.Timestamp.now() - start_time).total_seconds()
+ logger.info(f'Tagged {phasing_mask.sum()} cards with phasing effects in {duration:.2f}s')
+
+ except Exception as e:
+ logger.error(f'Error in tag_for_phasing: {str(e)}')
+ raise
+
## Spot removal
def create_removal_text_mask(df: pd.DataFrame) -> pd.Series:
"""Create a boolean mask for cards with removal text patterns.
diff --git a/code/tests/test_additional_theme_config.py b/code/tests/test_additional_theme_config.py
index 1d3dc80..5c6aae7 100644
--- a/code/tests/test_additional_theme_config.py
+++ b/code/tests/test_additional_theme_config.py
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
-from headless_runner import _resolve_additional_theme_inputs, _parse_theme_list
+from code.headless_runner import resolve_additional_theme_inputs as _resolve_additional_theme_inputs, _parse_theme_list
def _write_catalog(path: Path) -> None:
diff --git a/code/tests/test_metadata_partition.py b/code/tests/test_metadata_partition.py
new file mode 100644
index 0000000..6b47960
--- /dev/null
+++ b/code/tests/test_metadata_partition.py
@@ -0,0 +1,300 @@
+"""Tests for M3 metadata/theme tag partition functionality.
+
+Tests cover:
+- Tag classification (metadata vs theme)
+- Column creation and data migration
+- Feature flag behavior
+- Compatibility with missing columns
+- CSV read/write with new schema
+"""
+import pandas as pd
+import pytest
+from code.tagging import tag_utils
+from code.tagging.tagger import _apply_metadata_partition
+
+
+class TestTagClassification:
+ """Tests for classify_tag function."""
+
+ def test_prefix_based_metadata(self):
+ """Metadata tags identified by prefix."""
+ assert tag_utils.classify_tag("Applied: Cost Reduction") == "metadata"
+ assert tag_utils.classify_tag("Bracket: Game Changer") == "metadata"
+ assert tag_utils.classify_tag("Diagnostic: Test") == "metadata"
+ assert tag_utils.classify_tag("Internal: Debug") == "metadata"
+
+ def test_exact_match_metadata(self):
+ """Metadata tags identified by exact match."""
+ assert tag_utils.classify_tag("Bracket: Game Changer") == "metadata"
+ assert tag_utils.classify_tag("Bracket: Staple") == "metadata"
+
+ def test_kindred_protection_metadata(self):
+ """Kindred protection tags are metadata."""
+ assert tag_utils.classify_tag("Knights Gain Protection") == "metadata"
+ assert tag_utils.classify_tag("Frogs Gain Protection") == "metadata"
+ assert tag_utils.classify_tag("Zombies Gain Protection") == "metadata"
+
+ def test_theme_classification(self):
+ """Regular gameplay tags are themes."""
+ assert tag_utils.classify_tag("Card Draw") == "theme"
+ assert tag_utils.classify_tag("Spellslinger") == "theme"
+ assert tag_utils.classify_tag("Tokens Matter") == "theme"
+ assert tag_utils.classify_tag("Ramp") == "theme"
+ assert tag_utils.classify_tag("Protection") == "theme"
+
+ def test_edge_cases(self):
+ """Edge cases in tag classification."""
+ # Empty string
+ assert tag_utils.classify_tag("") == "theme"
+
+ # Similar but not exact matches
+ assert tag_utils.classify_tag("Apply: Something") == "theme" # Wrong prefix
+ assert tag_utils.classify_tag("Knights Have Protection") == "theme" # Not "Gain"
+
+ # Case sensitivity
+ assert tag_utils.classify_tag("applied: Cost Reduction") == "theme" # Lowercase
+
+
+class TestMetadataPartition:
+ """Tests for _apply_metadata_partition function."""
+
+ def test_basic_partition(self, monkeypatch):
+ """Basic partition splits tags correctly."""
+ monkeypatch.setenv('TAG_METADATA_SPLIT', '1')
+
+ df = pd.DataFrame({
+ 'name': ['Card A', 'Card B'],
+ 'themeTags': [
+ ['Card Draw', 'Applied: Cost Reduction'],
+ ['Spellslinger', 'Bracket: Game Changer', 'Tokens Matter']
+ ]
+ })
+
+ df_out, diag = _apply_metadata_partition(df)
+
+ # Check theme tags
+ assert df_out.loc[0, 'themeTags'] == ['Card Draw']
+ assert df_out.loc[1, 'themeTags'] == ['Spellslinger', 'Tokens Matter']
+
+ # Check metadata tags
+ assert df_out.loc[0, 'metadataTags'] == ['Applied: Cost Reduction']
+ assert df_out.loc[1, 'metadataTags'] == ['Bracket: Game Changer']
+
+ # Check diagnostics
+ assert diag['enabled'] is True
+ assert diag['rows_with_tags'] == 2
+ assert diag['metadata_tags_moved'] == 2
+ assert diag['theme_tags_kept'] == 3
+
+ def test_empty_tags(self, monkeypatch):
+ """Handles empty tag lists."""
+ monkeypatch.setenv('TAG_METADATA_SPLIT', '1')
+
+ df = pd.DataFrame({
+ 'name': ['Card A', 'Card B'],
+ 'themeTags': [[], ['Card Draw']]
+ })
+
+ df_out, diag = _apply_metadata_partition(df)
+
+ assert df_out.loc[0, 'themeTags'] == []
+ assert df_out.loc[0, 'metadataTags'] == []
+ assert df_out.loc[1, 'themeTags'] == ['Card Draw']
+ assert df_out.loc[1, 'metadataTags'] == []
+
+ assert diag['rows_with_tags'] == 1
+
+ def test_all_metadata_tags(self, monkeypatch):
+ """Handles rows with only metadata tags."""
+ monkeypatch.setenv('TAG_METADATA_SPLIT', '1')
+
+ df = pd.DataFrame({
+ 'name': ['Card A'],
+ 'themeTags': [['Applied: Cost Reduction', 'Bracket: Game Changer']]
+ })
+
+ df_out, diag = _apply_metadata_partition(df)
+
+ assert df_out.loc[0, 'themeTags'] == []
+ assert df_out.loc[0, 'metadataTags'] == ['Applied: Cost Reduction', 'Bracket: Game Changer']
+
+ assert diag['metadata_tags_moved'] == 2
+ assert diag['theme_tags_kept'] == 0
+
+ def test_all_theme_tags(self, monkeypatch):
+ """Handles rows with only theme tags."""
+ monkeypatch.setenv('TAG_METADATA_SPLIT', '1')
+
+ df = pd.DataFrame({
+ 'name': ['Card A'],
+ 'themeTags': [['Card Draw', 'Ramp', 'Spellslinger']]
+ })
+
+ df_out, diag = _apply_metadata_partition(df)
+
+ assert df_out.loc[0, 'themeTags'] == ['Card Draw', 'Ramp', 'Spellslinger']
+ assert df_out.loc[0, 'metadataTags'] == []
+
+ assert diag['metadata_tags_moved'] == 0
+ assert diag['theme_tags_kept'] == 3
+
+ def test_feature_flag_disabled(self, monkeypatch):
+ """Feature flag disables partition."""
+ monkeypatch.setenv('TAG_METADATA_SPLIT', '0')
+
+ df = pd.DataFrame({
+ 'name': ['Card A'],
+ 'themeTags': [['Card Draw', 'Applied: Cost Reduction']]
+ })
+
+ df_out, diag = _apply_metadata_partition(df)
+
+ # Should not create metadataTags column
+ assert 'metadataTags' not in df_out.columns
+
+ # Should not modify themeTags
+ assert df_out.loc[0, 'themeTags'] == ['Card Draw', 'Applied: Cost Reduction']
+
+ # Should indicate disabled
+ assert diag['enabled'] is False
+
+ def test_missing_theme_tags_column(self, monkeypatch):
+ """Handles missing themeTags column gracefully."""
+ monkeypatch.setenv('TAG_METADATA_SPLIT', '1')
+
+ df = pd.DataFrame({
+ 'name': ['Card A'],
+ 'other_column': ['value']
+ })
+
+ df_out, diag = _apply_metadata_partition(df)
+
+ # Should return unchanged
+ assert 'themeTags' not in df_out.columns
+ assert 'metadataTags' not in df_out.columns
+
+ # Should indicate error
+ assert diag['enabled'] is True
+ assert 'error' in diag
+
+ def test_non_list_tags(self, monkeypatch):
+ """Handles non-list values in themeTags."""
+ monkeypatch.setenv('TAG_METADATA_SPLIT', '1')
+
+ df = pd.DataFrame({
+ 'name': ['Card A', 'Card B', 'Card C'],
+ 'themeTags': [['Card Draw'], None, 'not a list']
+ })
+
+ df_out, diag = _apply_metadata_partition(df)
+
+ # Only first row should be processed
+ assert df_out.loc[0, 'themeTags'] == ['Card Draw']
+ assert df_out.loc[0, 'metadataTags'] == []
+
+ assert diag['rows_with_tags'] == 1
+
+ def test_kindred_protection_partition(self, monkeypatch):
+ """Kindred protection tags are moved to metadata."""
+ monkeypatch.setenv('TAG_METADATA_SPLIT', '1')
+
+ df = pd.DataFrame({
+ 'name': ['Card A'],
+ 'themeTags': [['Protection', 'Knights Gain Protection', 'Card Draw']]
+ })
+
+ df_out, diag = _apply_metadata_partition(df)
+
+ assert 'Protection' in df_out.loc[0, 'themeTags']
+ assert 'Card Draw' in df_out.loc[0, 'themeTags']
+ assert 'Knights Gain Protection' in df_out.loc[0, 'metadataTags']
+
+ def test_diagnostics_structure(self, monkeypatch):
+ """Diagnostics contain expected fields."""
+ monkeypatch.setenv('TAG_METADATA_SPLIT', '1')
+
+ df = pd.DataFrame({
+ 'name': ['Card A'],
+ 'themeTags': [['Card Draw', 'Applied: Cost Reduction']]
+ })
+
+ df_out, diag = _apply_metadata_partition(df)
+
+ # Check required diagnostic fields
+ assert 'enabled' in diag
+ assert 'total_rows' in diag
+ assert 'rows_with_tags' in diag
+ assert 'metadata_tags_moved' in diag
+ assert 'theme_tags_kept' in diag
+ assert 'unique_metadata_tags' in diag
+ assert 'unique_theme_tags' in diag
+ assert 'most_common_metadata' in diag
+ assert 'most_common_themes' in diag
+
+ # Check types
+ assert isinstance(diag['most_common_metadata'], list)
+ assert isinstance(diag['most_common_themes'], list)
+
+
+class TestCSVCompatibility:
+ """Tests for CSV read/write with new schema."""
+
+ def test_csv_roundtrip_with_metadata(self, tmp_path, monkeypatch):
+ """CSV roundtrip preserves both columns."""
+ monkeypatch.setenv('TAG_METADATA_SPLIT', '1')
+
+ csv_path = tmp_path / "test_cards.csv"
+
+ # Create initial dataframe
+ df = pd.DataFrame({
+ 'name': ['Card A'],
+ 'themeTags': [['Card Draw', 'Ramp']],
+ 'metadataTags': [['Applied: Cost Reduction']]
+ })
+
+ # Write to CSV
+ df.to_csv(csv_path, index=False)
+
+ # Read back
+ df_read = pd.read_csv(
+ csv_path,
+ converters={'themeTags': pd.eval, 'metadataTags': pd.eval}
+ )
+
+ # Verify data preserved
+ assert df_read.loc[0, 'themeTags'] == ['Card Draw', 'Ramp']
+ assert df_read.loc[0, 'metadataTags'] == ['Applied: Cost Reduction']
+
+ def test_csv_backward_compatible(self, tmp_path, monkeypatch):
+ """Can read old CSVs without metadataTags."""
+ monkeypatch.setenv('TAG_METADATA_SPLIT', '1')
+
+ csv_path = tmp_path / "old_cards.csv"
+
+ # Create old-style CSV without metadataTags
+ df = pd.DataFrame({
+ 'name': ['Card A'],
+ 'themeTags': [['Card Draw', 'Applied: Cost Reduction']]
+ })
+ df.to_csv(csv_path, index=False)
+
+ # Read back
+ df_read = pd.read_csv(csv_path, converters={'themeTags': pd.eval})
+
+ # Should read successfully
+ assert 'themeTags' in df_read.columns
+ assert 'metadataTags' not in df_read.columns
+ assert df_read.loc[0, 'themeTags'] == ['Card Draw', 'Applied: Cost Reduction']
+
+ # Apply partition
+ df_partitioned, _ = _apply_metadata_partition(df_read)
+
+ # Should now have both columns
+ assert 'themeTags' in df_partitioned.columns
+ assert 'metadataTags' in df_partitioned.columns
+ assert df_partitioned.loc[0, 'themeTags'] == ['Card Draw']
+ assert df_partitioned.loc[0, 'metadataTags'] == ['Applied: Cost Reduction']
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py
index f84d9a1..957936b 100644
--- a/code/web/routes/decks.py
+++ b/code/web/routes/decks.py
@@ -159,11 +159,18 @@ def _read_csv_summary(csv_path: Path) -> Tuple[dict, Dict[str, int], Dict[str, i
# Type counts/cards (exclude commander entry from distribution)
if not is_commander:
type_counts[cat] = type_counts.get(cat, 0) + cnt
+ # M5: Extract metadata tags column if present
+ metadata_tags_raw = ''
+ metadata_idx = headers.index('MetadataTags') if 'MetadataTags' in headers else -1
+ if metadata_idx >= 0 and metadata_idx < len(row):
+ metadata_tags_raw = row[metadata_idx] or ''
+ metadata_tags_list = [t.strip() for t in metadata_tags_raw.split(';') if t.strip()]
type_cards.setdefault(cat, []).append({
'name': name,
'count': cnt,
'role': role,
'tags': tags_list,
+ 'metadata_tags': metadata_tags_list, # M5: Include metadata tags
})
# Curve
diff --git a/code/web/templates/base.html b/code/web/templates/base.html
index 050d57c..b8a0d88 100644
--- a/code/web/templates/base.html
+++ b/code/web/templates/base.html
@@ -1012,6 +1012,7 @@
var role = (attr('data-role')||'').trim();
var reasonsRaw = attr('data-reasons')||'';
var tagsRaw = attr('data-tags')||'';
+ var metadataTagsRaw = attr('data-metadata-tags')||''; // M5: Extract metadata tags
var reasonsRaw = attr('data-reasons')||'';
var roleEl = panel.querySelector('.hcp-role');
var hasFlip = !!card.querySelector('.dfc-toggle');
@@ -1116,6 +1117,14 @@
tagsEl.style.display = 'none';
} else {
var tagText = allTags.map(displayLabel).join(', ');
+ // M5: Temporarily append metadata tags for debugging
+ if(metadataTagsRaw && metadataTagsRaw.trim()){
+ var metaTags = metadataTagsRaw.split(',').map(function(t){return t.trim();}).filter(Boolean);
+ if(metaTags.length){
+ var metaText = metaTags.map(displayLabel).join(', ');
+ tagText = tagText ? (tagText + ' | META: ' + metaText) : ('META: ' + metaText);
+ }
+ }
tagsEl.textContent = tagText;
tagsEl.style.display = tagText ? '' : 'none';
}
diff --git a/code/web/templates/partials/deck_summary.html b/code/web/templates/partials/deck_summary.html
index e327bef..d7b0e0d 100644
--- a/code/web/templates/partials/deck_summary.html
+++ b/code/web/templates/partials/deck_summary.html
@@ -74,7 +74,7 @@
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
{{ cnt }}
x
- {{ c.name }}
+ {{ c.name }}
{% if c.dfc_land %}
DFC land{% if c.dfc_adds_extra_land %} +1{% endif %}
diff --git a/config/themes/theme_list.json b/config/themes/theme_list.json
index ba5fa1d..3d76807 100644
--- a/config/themes/theme_list.json
+++ b/config/themes/theme_list.json
@@ -47,9 +47,9 @@
"primary_color": "Black",
"secondary_color": "Blue",
"example_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)"
+ "Krenko, Tin Street Kingpin - Synergy (Counters Matter)"
],
"example_cards": [
"Wall of Roots",
@@ -118,6 +118,9 @@
"Yawgmoth, Thran Physician",
"Tezzeret's Gambit"
],
+ "synergy_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Infect)"
+ ],
"popularity_bucket": "Common",
"editorial_quality": "draft",
"description": "Spreads -1/-1 counters for removal, attrition, and loop engines leveraging death & sacrifice triggers. Synergies like Proliferate and Counters Matter reinforce the plan."
@@ -152,7 +155,7 @@
"Silverflame Ritual"
],
"synergy_commanders": [
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -188,8 +191,8 @@
"Jetfire, Ingenious Scientist // Jetfire, Air Guardian"
],
"synergy_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Yahenni, Undying Partisan - Synergy (Counters Matter)",
- "Heliod, Sun-Crowned - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Voltron)"
],
"popularity_bucket": "Rare",
@@ -487,7 +490,7 @@
"Cosima, God of the Voyage // The Omenkeel"
],
"synergy_commanders": [
- "Rishkar, Peema Renegade - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
@@ -722,8 +725,8 @@
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)",
- "Heliod, Sun-Crowned - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
+ "Yahenni, Undying Partisan - Synergy (Counters Matter)"
],
"example_cards": [
"Kilnmouth Dragon",
@@ -2456,21 +2459,21 @@
"primary_color": "Red",
"secondary_color": "Black",
"example_commanders": [
+ "Edgar, Charmed Groom // Edgar Markov's Coffin",
"Old Rutstein",
"Kamber, the Plunderer",
"Strefan, Maurer Progenitor",
- "Anje, Maid of Dishonor",
- "Shilgengar, Sire of Famine"
+ "Anje, Maid of Dishonor"
],
"example_cards": [
"Voldaren Estate",
"Blood for the Blood God!",
+ "Edgar, Charmed Groom // Edgar Markov's Coffin",
"Old Rutstein",
"Transmutation Font",
"Glass-Cast Heart",
"Voldaren Epicure",
- "Font of Agonies",
- "Exsanguinator Cavalry"
+ "Font of Agonies"
],
"synergy_commanders": [
"Indoraptor, the Perfect Hybrid - Synergy (Bloodthirst)",
@@ -2491,9 +2494,9 @@
"primary_color": "Red",
"secondary_color": "Green",
"example_commanders": [
+ "Edgar, Charmed Groom // Edgar Markov's Coffin - Synergy (Blood Token)",
"Old Rutstein - Synergy (Blood Token)",
"Kamber, the Plunderer - Synergy (Blood Token)",
- "Strefan, Maurer Progenitor - Synergy (Blood Token)",
"Etali, Primal Storm - Synergy (Aggro)",
"Ragavan, Nimble Pilferer - Synergy (Aggro)"
],
@@ -2528,9 +2531,9 @@
"secondary_color": "Green",
"example_commanders": [
"Indoraptor, the Perfect Hybrid",
+ "Edgar, Charmed Groom // Edgar Markov's Coffin - Synergy (Blood Token)",
"Old Rutstein - Synergy (Blood Token)",
"Kamber, the Plunderer - Synergy (Blood Token)",
- "Strefan, Maurer Progenitor - Synergy (Blood Token)",
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)"
],
"example_cards": [
@@ -2595,11 +2598,11 @@
"id": "board-wipes",
"theme": "Board Wipes",
"synergies": [
- "Bracket:MassLandDenial",
"Pingers",
"Interaction",
"Lore Counters",
- "Sagas Matter"
+ "Sagas Matter",
+ "Ore Counters"
],
"primary_color": "Red",
"secondary_color": "White",
@@ -2621,13 +2624,14 @@
"Scavenger Grounds"
],
"synergy_commanders": [
- "Hokori, Dust Drinker - Synergy (Bracket:MassLandDenial)",
- "Myojin of Infinite Rage - Synergy (Bracket:MassLandDenial)",
- "Elas il-Kor, Sadistic Pilgrim - Synergy (Pingers)"
+ "Elas il-Kor, Sadistic Pilgrim - Synergy (Pingers)",
+ "Niv-Mizzet, Parun - Synergy (Pingers)",
+ "Boromir, Warden of the Tower - Synergy (Interaction)",
+ "Urabrask // The Great Work - Synergy (Lore Counters)"
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Builds around Board Wipes leveraging synergies with Bracket:MassLandDenial and Pingers."
+ "description": "Builds around Board Wipes leveraging synergies with Pingers and Interaction."
},
{
"id": "boast",
@@ -2701,7 +2705,7 @@
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
"Samut, Voice of Dissent - Synergy (Combat Tricks)",
"Naru Meha, Master Wizard - Synergy (Combat Tricks)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -2729,160 +2733,6 @@
"editorial_quality": "draft",
"description": "Accumulates bounty counters to unlock scaling payoffs, removal triggers, or delayed value conversions."
},
- {
- "id": "bracketextraturn",
- "theme": "Bracket:ExtraTurn",
- "synergies": [
- "Spells Matter",
- "Spellslinger",
- "Big Mana",
- "Counters Matter"
- ],
- "primary_color": "Blue",
- "secondary_color": "Red",
- "example_commanders": [
- "Medomai the Ageless",
- "Ultimecia, Time Sorceress // Ultimecia, Omnipotent",
- "Lotho, Corrupt Shirriff - Synergy (Spells Matter)",
- "Birgi, God of Storytelling // Harnfel, Horn of Bounty - Synergy (Spells Matter)",
- "Niv-Mizzet, Parun - Synergy (Spells Matter)"
- ],
- "example_cards": [
- "Final Fortune",
- "Time Warp",
- "Teferi, Master of Time",
- "Rise of the Eldrazi",
- "Nexus of Fate",
- "Expropriate",
- "Time Sieve",
- "Temporal Mastery"
- ],
- "synergy_commanders": [
- "Talrand, Sky Summoner - Synergy (Spellslinger)",
- "Mangara, the Diplomat - Synergy (Spellslinger)",
- "Syr Konrad, the Grim - Synergy (Big Mana)"
- ],
- "popularity_bucket": "Rare",
- "editorial_quality": "draft",
- "description": "Builds around Bracket:ExtraTurn leveraging synergies with Spells Matter and Spellslinger."
- },
- {
- "id": "bracketgamechanger",
- "theme": "Bracket:GameChanger",
- "synergies": [
- "Bracket:TutorNonland",
- "Draw Triggers",
- "Wheels",
- "Stax",
- "Topdeck"
- ],
- "primary_color": "Blue",
- "secondary_color": "Black",
- "example_commanders": [
- "Urza, Lord High Artificer",
- "Kinnan, Bonder Prodigy",
- "Tergrid, God of Fright // Tergrid's Lantern",
- "Jin-Gitaxias, Core Augur",
- "Vorinclex, Voice of Hunger"
- ],
- "example_cards": [
- "Rhystic Study",
- "Cyclonic Rift",
- "Smothering Tithe",
- "Demonic Tutor",
- "Ancient Tomb",
- "Fierce Guardianship",
- "The One Ring",
- "Teferi's Protection"
- ],
- "synergy_commanders": [
- "Magda, Brazen Outlaw - Synergy (Bracket:TutorNonland)",
- "Razaketh, the Foulblooded - Synergy (Bracket:TutorNonland)",
- "Varragoth, Bloodsky Sire - Synergy (Bracket:TutorNonland)",
- "Braids, Arisen Nightmare - Synergy (Draw Triggers)",
- "Loran of the Third Path - Synergy (Draw Triggers)",
- "Sheoldred, the Apocalypse - Synergy (Wheels)"
- ],
- "popularity_bucket": "Niche",
- "editorial_quality": "draft",
- "description": "Builds around Bracket:GameChanger leveraging synergies with Bracket:TutorNonland and Draw Triggers."
- },
- {
- "id": "bracketmasslanddenial",
- "theme": "Bracket:MassLandDenial",
- "synergies": [
- "Board Wipes",
- "Interaction",
- "Spells Matter",
- "Spellslinger",
- "Big Mana"
- ],
- "primary_color": "Red",
- "secondary_color": "White",
- "example_commanders": [
- "Hokori, Dust Drinker",
- "Myojin of Infinite Rage",
- "Syr Konrad, the Grim - Synergy (Board Wipes)",
- "Purphoros, God of the Forge - Synergy (Board Wipes)",
- "Ashling, Flame Dancer - Synergy (Board Wipes)"
- ],
- "example_cards": [
- "Blood Moon",
- "Magus of the Moon",
- "Winter Orb",
- "Winter Moon",
- "Armageddon",
- "Harbinger of the Seas",
- "Back to Basics",
- "Static Orb"
- ],
- "synergy_commanders": [
- "Boromir, Warden of the Tower - Synergy (Interaction)",
- "Avacyn, Angel of Hope - Synergy (Interaction)",
- "Lotho, Corrupt Shirriff - Synergy (Spells Matter)"
- ],
- "popularity_bucket": "Niche",
- "editorial_quality": "draft",
- "description": "Builds around Bracket:MassLandDenial leveraging synergies with Board Wipes and Interaction."
- },
- {
- "id": "brackettutornonland",
- "theme": "Bracket:TutorNonland",
- "synergies": [
- "Transmute",
- "Bracket:GameChanger",
- "Mercenary Kindred",
- "Rebel Kindred",
- "Toolbox"
- ],
- "primary_color": "Black",
- "secondary_color": "Green",
- "example_commanders": [
- "Magda, Brazen Outlaw",
- "Razaketh, the Foulblooded",
- "Varragoth, Bloodsky Sire",
- "Sidisi, Undead Vizier",
- "Oswald Fiddlebender"
- ],
- "example_cards": [
- "Demonic Tutor",
- "Vampiric Tutor",
- "Enlightened Tutor",
- "Urza's Saga",
- "Mystical Tutor",
- "Worldly Tutor",
- "Gamble",
- "Diabolic Intent"
- ],
- "synergy_commanders": [
- "Urza, Lord High Artificer - Synergy (Bracket:GameChanger)",
- "Kinnan, Bonder Prodigy - Synergy (Bracket:GameChanger)",
- "Kellogg, Dangerous Mind - Synergy (Mercenary Kindred)"
- ],
- "popularity_bucket": "Common",
- "editorial_quality": "draft",
- "description": "Builds around Bracket:TutorNonland leveraging synergies with Transmute and Bracket:GameChanger."
- },
{
"id": "brushwagg-kindred",
"theme": "Brushwagg Kindred",
@@ -3193,6 +3043,7 @@
"Seasoned Dungeoneer"
],
"synergy_commanders": [
+ "Kellan, Daring Traveler // Journey On - Synergy (Map Token)",
"Selvala, Heart of the Wilds - Synergy (Scout Kindred)"
],
"popularity_bucket": "Niche",
@@ -3575,7 +3426,7 @@
"synergy_commanders": [
"Codsworth, Handy Helper - Synergy (Mana Rock)",
"Karn, Legacy Reforged - Synergy (Mana Rock)",
- "Rishkar, Peema Renegade - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
@@ -4541,9 +4392,9 @@
"secondary_color": "Green",
"example_commanders": [
"Ixhel, Scion of Atraxa",
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Poison Counters)",
"Skrelv, Defector Mite - Synergy (Poison Counters)",
"Skithiryx, the Blight Dragon - Synergy (Poison Counters)",
- "Fynn, the Fangbearer - Synergy (Poison Counters)",
"Yawgmoth, Thran Physician - Synergy (Infect)"
],
"example_cards": [
@@ -4682,11 +4533,11 @@
"primary_color": "Green",
"secondary_color": "White",
"example_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness",
"Rishkar, Peema Renegade",
"Krenko, Tin Street Kingpin",
"Yawgmoth, Thran Physician",
- "Yahenni, Undying Partisan",
- "Heliod, Sun-Crowned"
+ "Yahenni, Undying Partisan"
],
"example_cards": [
"The One Ring",
@@ -4988,9 +4839,9 @@
"example_commanders": [
"The Pride of Hull Clade",
"Kalakscion, Hunger Tyrant",
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)"
+ "Krenko, Tin Street Kingpin - Synergy (Counters Matter)"
],
"example_cards": [
"The Pride of Hull Clade",
@@ -5003,8 +4854,8 @@
"Algae Gharial"
],
"synergy_commanders": [
+ "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
"Yahenni, Undying Partisan - Synergy (+1/+1 Counters)",
- "Heliod, Sun-Crowned - Synergy (+1/+1 Counters)",
"Azusa, Lost but Seeking - Synergy (Toughness Matters)"
],
"popularity_bucket": "Rare",
@@ -5041,8 +4892,8 @@
"Elephant Grass"
],
"synergy_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Enchantments Matter)"
],
"popularity_bucket": "Niche",
@@ -5585,8 +5436,8 @@
"Azusa, Lost but Seeking - Synergy (Lands Matter)",
"Tatyova, Benthic Druid - Synergy (Lands Matter)",
"Sheoldred, Whispering One - Synergy (Lands Matter)",
- "Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
+ "Rishkar, Peema Renegade - Synergy (Counters Matter)"
],
"example_cards": [
"Sandstone Needle",
@@ -5740,7 +5591,7 @@
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"example_cards": [
"Scourge of the Throne",
@@ -5753,7 +5604,7 @@
"Marchesa's Emissary"
],
"synergy_commanders": [
- "Heliod, Sun-Crowned - Synergy (Counters Matter)",
+ "Yahenni, Undying Partisan - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Voltron)"
],
"popularity_bucket": "Rare",
@@ -5862,7 +5713,7 @@
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"example_cards": [
"Mycoloth",
@@ -5875,7 +5726,7 @@
"Voracious Dragon"
],
"synergy_commanders": [
- "Heliod, Sun-Crowned - Synergy (Counters Matter)",
+ "Yahenni, Undying Partisan - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Voltron)"
],
"popularity_bucket": "Rare",
@@ -6107,8 +5958,8 @@
"Kodama of the West Tree - Synergy (Spirit Kindred)",
"Kodama of the East Tree - Synergy (Spirit Kindred)",
"Junji, the Midnight Sky - Synergy (Spirit Kindred)",
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
"Syr Konrad, the Grim - Synergy (Big Mana)"
],
"popularity_bucket": "Rare",
@@ -6785,7 +6636,7 @@
"Plargg and Nassari",
"Yusri, Fortune's Flame",
"Najal, the Storm Runner",
- "Niv-Mizzet, Parun - Synergy (Flying)"
+ "Uvilda, Dean of Perfection // Nassari, Dean of Expression"
],
"example_cards": [
"Veyran, Voice of Duality",
@@ -6794,10 +6645,11 @@
"Najal, the Storm Runner",
"Frenetic Efreet",
"Efreet Flamepainter",
- "Emissary of Grudges",
- "Capricious Efreet"
+ "Uvilda, Dean of Perfection // Nassari, Dean of Expression",
+ "Emissary of Grudges"
],
"synergy_commanders": [
+ "Niv-Mizzet, Parun - Synergy (Flying)",
"Avacyn, Angel of Hope - Synergy (Flying)",
"Old Gnawbone - Synergy (Flying)",
"Syr Konrad, the Grim - Synergy (Burn)",
@@ -7193,7 +7045,7 @@
"Auras",
"Disturb",
"Enchantments Matter",
- "Voltron"
+ "Protection from Color"
],
"primary_color": "Blue",
"secondary_color": "White",
@@ -7895,7 +7747,7 @@
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"example_cards": [
"Gyre Sage",
@@ -7908,7 +7760,7 @@
"Experiment One"
],
"synergy_commanders": [
- "Heliod, Sun-Crowned - Synergy (Counters Matter)",
+ "Yahenni, Undying Partisan - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Voltron)"
],
"popularity_bucket": "Rare",
@@ -8020,7 +7872,7 @@
"The Indomitable - Synergy (Vehicles)",
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -8159,6 +8011,7 @@
"Nicanzil, Current Conductor"
],
"synergy_commanders": [
+ "Kellan, Daring Traveler // Journey On - Synergy (Map Token)",
"Selvala, Heart of the Wilds - Synergy (Scout Kindred)"
],
"popularity_bucket": "Niche",
@@ -8285,8 +8138,8 @@
"primary_color": "Green",
"secondary_color": "Black",
"example_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Enchantments Matter)"
],
"example_cards": [
@@ -8314,8 +8167,8 @@
"primary_color": "Green",
"secondary_color": "Black",
"example_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Enchantments Matter)"
],
"example_cards": [
@@ -8478,7 +8331,8 @@
"synergies": [
"Big Mana",
"Spells Matter",
- "Spellslinger"
+ "Spellslinger",
+ "Interaction"
],
"primary_color": "Green",
"secondary_color": "Red",
@@ -8591,8 +8445,8 @@
"Syr Konrad, the Grim - Synergy (Mill)",
"Emry, Lurker of the Loch - Synergy (Mill)",
"Sheoldred, Whispering One - Synergy (Mill)",
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
"Six - Synergy (Reanimate)"
],
"popularity_bucket": "Rare",
@@ -8771,8 +8625,8 @@
"Evoke",
"Combat Tricks",
"Faerie Kindred",
- "Wolf Kindred",
- "Equip"
+ "Hexproof",
+ "Wolf Kindred"
],
"primary_color": "Blue",
"secondary_color": "White",
@@ -8927,6 +8781,7 @@
],
"synergy_commanders": [
"Otharri, Suns' Glory - Synergy (Phoenix Kindred)",
+ "Joshua, Phoenix's Dominant // Phoenix, Warden of Fire - Synergy (Phoenix Kindred)",
"Syrix, Carrier of the Flame - Synergy (Phoenix Kindred)",
"Ezrim, Agency Chief - Synergy (Archon Kindred)",
"Krond the Dawn-Clad - Synergy (Archon Kindred)",
@@ -9332,8 +9187,8 @@
],
"synergy_commanders": [
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Yahenni, Undying Partisan - Synergy (Counters Matter)",
- "Heliod, Sun-Crowned - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Voltron)"
],
"popularity_bucket": "Rare",
@@ -9920,7 +9775,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of God creatures into play with shared payoffs (e.g., Indestructible and Protection)."
+ "description": "Focuses on getting a high number of God creatures into play with shared payoffs (e.g., Indestructible and Transform)."
},
{
"id": "gold-token",
@@ -9939,7 +9794,7 @@
"Golden Argosy",
"King Macar, the Gold-Cursed",
"Goldbug, Humanity's Ally // Goldbug, Scrappy Scout",
- "Raksha Golden Cub"
+ "Tetzin, Gnome Champion // The Golden-Gear Colossus"
],
"example_cards": [
"Curse of Opulence",
@@ -10074,7 +9929,7 @@
"Novijen Sages"
],
"synergy_commanders": [
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -10472,7 +10327,7 @@
"Ulasht, the Hate Seed - Synergy (Hellion Kindred)",
"Thromok the Insatiable - Synergy (Hellion Kindred)",
"Otharri, Suns' Glory - Synergy (Phoenix Kindred)",
- "Syrix, Carrier of the Flame - Synergy (Phoenix Kindred)"
+ "Joshua, Phoenix's Dominant // Phoenix, Warden of Fire - Synergy (Phoenix Kindred)"
],
"popularity_bucket": "Common",
"editorial_quality": "draft",
@@ -10700,39 +10555,41 @@
"theme": "Hexproof",
"synergies": [
"Hexproof from",
- "Stax",
- "Beast Kindred",
- "Elemental Kindred",
- "Outlaw Kindred"
+ "Protection",
+ "Indestructible",
+ "Flash",
+ "Combat Tricks"
],
"primary_color": "Green",
"secondary_color": "Blue",
"example_commanders": [
- "General Ferrous Rokiric",
- "Silumgar, the Drifting Death",
- "Elenda, Saint of Dusk",
- "Sigarda, Host of Herons",
- "Lazav, Dimir Mastermind"
+ "Padeem, Consul of Innovation",
+ "Skrelv, Defector Mite",
+ "Shalai, Voice of Plenty",
+ "Saryth, the Viper's Fang",
+ "Odric, Lunarch Marshal"
],
"example_cards": [
- "Lotus Field",
- "Valgavoth's Lair",
- "Volatile Stormdrake",
- "Sylvan Caryatid",
- "Invisible Stalker",
- "Slippery Bogbonder",
- "General Ferrous Rokiric",
- "Silumgar, the Drifting Death"
+ "Swiftfoot Boots",
+ "Heroic Intervention",
+ "Tamiyo's Safekeeping",
+ "Dawn's Truce",
+ "Plaza of Heroes",
+ "Veil of Summer",
+ "Snakeskin Veil",
+ "Tyvar's Stand"
],
"synergy_commanders": [
+ "General Ferrous Rokiric - Synergy (Hexproof from)",
+ "Elenda, Saint of Dusk - Synergy (Hexproof from)",
"Niv-Mizzet, Guildpact - Synergy (Hexproof from)",
- "Kutzil, Malamet Exemplar - Synergy (Stax)",
- "Lotho, Corrupt Shirriff - Synergy (Stax)",
- "Loot, Exuberant Explorer - Synergy (Beast Kindred)"
+ "Boromir, Warden of the Tower - Synergy (Protection)",
+ "Avacyn, Angel of Hope - Synergy (Protection)",
+ "Toski, Bearer of Secrets - Synergy (Indestructible)"
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Builds around Hexproof leveraging synergies with Hexproof from and Protection."
+ "description": "Builds around Hexproof leveraging synergies with Hexproof from and Stax."
},
{
"id": "hexproof-from",
@@ -10760,11 +10617,13 @@
"Niv-Mizzet, Supreme"
],
"synergy_commanders": [
- "Silumgar, the Drifting Death - Synergy (Hexproof)"
+ "Padeem, Consul of Innovation - Synergy (Hexproof)",
+ "Skrelv, Defector Mite - Synergy (Hexproof)",
+ "Shalai, Voice of Plenty - Synergy (Hexproof)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Builds around Hexproof from leveraging synergies with Hexproof and Protection."
+ "description": "Builds around Hexproof from leveraging synergies with Hexproof."
},
{
"id": "hideaway",
@@ -11215,9 +11074,9 @@
"primary_color": "Blue",
"secondary_color": "Black",
"example_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)"
+ "Krenko, Tin Street Kingpin - Synergy (Counters Matter)"
],
"example_cards": [
"Dark Depths",
@@ -11551,9 +11410,9 @@
"synergies": [
"God Kindred",
"Protection",
- "Lifegain",
- "Life Matters",
- "Stax"
+ "Hexproof",
+ "Lifelink",
+ "Interaction"
],
"primary_color": "White",
"secondary_color": "Black",
@@ -11561,24 +11420,23 @@
"Toski, Bearer of Secrets",
"Purphoros, God of the Forge",
"Etali, Primal Conqueror // Etali, Primal Sickness",
- "Avacyn, Angel of Hope",
- "Heliod, Sun-Crowned"
+ "Boromir, Warden of the Tower",
+ "Avacyn, Angel of Hope"
],
"example_cards": [
+ "Heroic Intervention",
"The One Ring",
+ "Boros Charm",
+ "Flawless Maneuver",
+ "Akroma's Will",
"Mithril Coat",
- "Darksteel Citadel",
- "Toski, Bearer of Secrets",
- "Purphoros, God of the Forge",
- "Etali, Primal Conqueror // Etali, Primal Sickness",
- "Brash Taunter",
- "Avacyn, Angel of Hope"
+ "Inspiring Call",
+ "Darksteel Citadel"
],
"synergy_commanders": [
"Birgi, God of Storytelling // Harnfel, Horn of Bounty - Synergy (God Kindred)",
"Ojer Taq, Deepest Foundation // Temple of Civilization - Synergy (God Kindred)",
- "Boromir, Warden of the Tower - Synergy (Protection)",
- "Tatyova, Benthic Druid - Synergy (Lifegain)"
+ "Padeem, Consul of Innovation - Synergy (Hexproof)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -11597,25 +11455,25 @@
"primary_color": "Green",
"secondary_color": "Black",
"example_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness",
"Yawgmoth, Thran Physician",
"Skrelv, Defector Mite",
"Vorinclex, Monstrous Raider",
- "Lae'zel, Vlaakith's Champion",
- "Tekuthal, Inquiry Dominus"
+ "Lae'zel, Vlaakith's Champion"
],
"example_cards": [
"Karn's Bastion",
"Doubling Season",
"Evolution Sage",
"Cankerbloom",
+ "Etali, Primal Conqueror // Etali, Primal Sickness",
"Thrummingbird",
"Yawgmoth, Thran Physician",
- "Tezzeret's Gambit",
- "Innkeeper's Talent"
+ "Tezzeret's Gambit"
],
"synergy_commanders": [
"Skithiryx, the Blight Dragon - Synergy (Poison Counters)",
- "Fynn, the Fangbearer - Synergy (Poison Counters)",
+ "Tekuthal, Inquiry Dominus - Synergy (Proliferate)",
"Karumonix, the Rat King - Synergy (Toxic)"
],
"popularity_bucket": "Uncommon",
@@ -11726,8 +11584,8 @@
"Tatyova, Benthic Druid - Synergy (Landfall)",
"Aesi, Tyrant of Gyre Strait - Synergy (Landfall)",
"Bristly Bill, Spine Sower - Synergy (Landfall)",
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Poison Counters)",
"Skrelv, Defector Mite - Synergy (Poison Counters)",
- "Skithiryx, the Blight Dragon - Synergy (Poison Counters)",
"Rishkar, Peema Renegade - Synergy (Druid Kindred)"
],
"popularity_bucket": "Common",
@@ -11779,10 +11637,10 @@
"Combat Tricks",
"Board Wipes",
"Counterspells",
- "Soulshift"
+ "Protection from Color"
],
"primary_color": "White",
- "secondary_color": "Blue",
+ "secondary_color": "Black",
"example_commanders": [
"Syr Konrad, the Grim",
"Purphoros, God of the Forge",
@@ -12308,8 +12166,8 @@
"Kodama of the West Tree - Synergy (Spirit Kindred)",
"Kodama of the East Tree - Synergy (Spirit Kindred)",
"Junji, the Midnight Sky - Synergy (Spirit Kindred)",
- "Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
+ "Rishkar, Peema Renegade - Synergy (Counters Matter)"
],
"example_cards": [
"Petalmane Baku",
@@ -13005,8 +12863,8 @@
"primary_color": "Blue",
"secondary_color": "White",
"example_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
"Emry, Lurker of the Loch - Synergy (Wizard Kindred)"
],
"example_cards": [
@@ -13036,8 +12894,8 @@
"primary_color": "Blue",
"secondary_color": "White",
"example_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
"Kutzil, Malamet Exemplar - Synergy (Warrior Kindred)"
],
"example_cards": [
@@ -13691,10 +13549,10 @@
"secondary_color": "Black",
"example_commanders": [
"Jeska, Thrice Reborn",
+ "Ral, Monsoon Mage // Ral, Leyline Prodigy",
"Commodore Guff",
"Mila, Crafty Companion // Lukka, Wayward Bonder",
- "Heart of Kiran",
- "Adeline, Resplendent Cathar - Synergy (Planeswalkers)"
+ "Heart of Kiran"
],
"example_cards": [
"Spark Double",
@@ -13707,11 +13565,12 @@
"Ral, Crackling Wit"
],
"synergy_commanders": [
+ "Adeline, Resplendent Cathar - Synergy (Planeswalkers)",
"Yawgmoth, Thran Physician - Synergy (Planeswalkers)",
"Vorinclex, Monstrous Raider - Synergy (Planeswalkers)",
"Lae'zel, Vlaakith's Champion - Synergy (Superfriends)",
"Tekuthal, Inquiry Dominus - Synergy (Superfriends)",
- "Rishkar, Peema Renegade - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -13994,11 +13853,11 @@
"primary_color": "Blue",
"secondary_color": "Green",
"example_commanders": [
+ "Kellan, Daring Traveler // Journey On",
"Hakbal of the Surging Soul - Synergy (Explore)",
"Amalia Benavides Aguirre - Synergy (Explore)",
"Nicanzil, Current Conductor - Synergy (Explore)",
- "Astrid Peth - Synergy (Card Selection)",
- "Francisco, Fowl Marauder - Synergy (Card Selection)"
+ "Astrid Peth - Synergy (Card Selection)"
],
"example_cards": [
"Get Lost",
@@ -14011,6 +13870,7 @@
"Spyglass Siren"
],
"synergy_commanders": [
+ "Francisco, Fowl Marauder - Synergy (Card Selection)",
"Ragavan, Nimble Pilferer - Synergy (Artifact Tokens)"
],
"popularity_bucket": "Rare",
@@ -14225,8 +14085,8 @@
"Saryth, the Viper's Fang - Synergy (Warlock Kindred)",
"Honest Rutstein - Synergy (Warlock Kindred)",
"Breena, the Demagogue - Synergy (Warlock Kindred)",
+ "Edgar, Charmed Groom // Edgar Markov's Coffin - Synergy (Blood Token)",
"Old Rutstein - Synergy (Blood Token)",
- "Kamber, the Plunderer - Synergy (Blood Token)",
"Ragavan, Nimble Pilferer - Synergy (Pirate Kindred)"
],
"popularity_bucket": "Common",
@@ -14267,7 +14127,7 @@
"Thalia, Heretic Cathar - Synergy (Soldier Kindred)",
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -14278,10 +14138,10 @@
"theme": "Mercenary Kindred",
"synergies": [
"Outlaw Kindred",
- "Bracket:TutorNonland",
"Horror Kindred",
"Phyrexian Kindred",
- "Human Kindred"
+ "Human Kindred",
+ "Zombie Kindred"
],
"primary_color": "Black",
"secondary_color": "Red",
@@ -14306,13 +14166,40 @@
"Ragavan, Nimble Pilferer - Synergy (Outlaw Kindred)",
"Lotho, Corrupt Shirriff - Synergy (Outlaw Kindred)",
"Captain Lannery Storm - Synergy (Outlaw Kindred)",
- "Magda, Brazen Outlaw - Synergy (Bracket:TutorNonland)",
- "Razaketh, the Foulblooded - Synergy (Bracket:TutorNonland)",
- "Mondrak, Glory Dominus - Synergy (Horror Kindred)"
+ "Mondrak, Glory Dominus - Synergy (Horror Kindred)",
+ "Solphim, Mayhem Dominus - Synergy (Horror Kindred)",
+ "Sheoldred, the Apocalypse - Synergy (Phyrexian Kindred)"
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Mercenary creatures into play with shared payoffs (e.g., Outlaw Kindred and Bracket:TutorNonland)."
+ "description": "Focuses on getting a high number of Mercenary creatures into play with shared payoffs (e.g., Outlaw Kindred and Horror Kindred)."
+ },
+ {
+ "id": "merfolk-gain-hexproof",
+ "theme": "Merfolk Gain Hexproof",
+ "synergies": [],
+ "primary_color": "Green",
+ "popularity_bucket": "Rare",
+ "editorial_quality": "draft",
+ "description": "Builds around the Merfolk Gain Hexproof theme and its supporting synergies."
+ },
+ {
+ "id": "merfolk-gain-shroud",
+ "theme": "Merfolk Gain Shroud",
+ "synergies": [],
+ "primary_color": "Blue",
+ "popularity_bucket": "Rare",
+ "editorial_quality": "draft",
+ "description": "Builds around the Merfolk Gain Shroud theme and its supporting synergies."
+ },
+ {
+ "id": "merfolk-gain-ward",
+ "theme": "Merfolk Gain Ward",
+ "synergies": [],
+ "primary_color": "Blue",
+ "popularity_bucket": "Rare",
+ "editorial_quality": "draft",
+ "description": "Builds around the Merfolk Gain Ward theme and its supporting synergies."
},
{
"id": "merfolk-kindred",
@@ -14627,8 +14514,8 @@
"Skrelv, Defector Mite",
"Vishgraz, the Doomhive",
"Ria Ivor, Bane of Bladehold",
- "Skithiryx, the Blight Dragon - Synergy (Poison Counters)",
- "Fynn, the Fangbearer - Synergy (Poison Counters)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Poison Counters)",
+ "Skithiryx, the Blight Dragon - Synergy (Poison Counters)"
],
"example_cards": [
"Skrelv, Defector Mite",
@@ -14971,7 +14858,7 @@
"Bonny Pall, Clearcutter - Synergy (Giant Kindred)",
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -15033,8 +14920,8 @@
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)",
- "Heliod, Sun-Crowned - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
+ "Yahenni, Undying Partisan - Synergy (Counters Matter)"
],
"example_cards": [
"Tragic Slip",
@@ -15286,7 +15173,7 @@
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"example_cards": [
"Everflowing Chalice",
@@ -15299,7 +15186,7 @@
"Bloodhusk Ritualist"
],
"synergy_commanders": [
- "Heliod, Sun-Crowned - Synergy (Counters Matter)",
+ "Yahenni, Undying Partisan - Synergy (Counters Matter)",
"Selvala, Heart of the Wilds - Synergy (Blink)"
],
"popularity_bucket": "Rare",
@@ -16045,7 +15932,7 @@
"Mondrak, Glory Dominus - Synergy (Phyrexian Kindred)",
"Sheoldred, the Apocalypse - Synergy (Phyrexian Kindred)",
"Elas il-Kor, Sadistic Pilgrim - Synergy (Phyrexian Kindred)",
- "Rishkar, Peema Renegade - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"example_cards": [
"Vat of Rebirth",
@@ -16058,7 +15945,7 @@
"Sawblade Scamp"
],
"synergy_commanders": [
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
+ "Rishkar, Peema Renegade - Synergy (Counters Matter)",
"Ragavan, Nimble Pilferer - Synergy (Artifacts Matter)"
],
"popularity_bucket": "Rare",
@@ -16115,7 +16002,7 @@
"Sakashima of a Thousand Faces - Synergy (Clones)",
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
@@ -16347,8 +16234,8 @@
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)",
- "Heliod, Sun-Crowned - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
+ "Yahenni, Undying Partisan - Synergy (Counters Matter)"
],
"example_cards": [
"Abzan Falconer",
@@ -16904,30 +16791,37 @@
"id": "phasing",
"theme": "Phasing",
"synergies": [
- "Flying",
- "Little Fellas"
+ "Protection",
+ "Equipment Matters",
+ "Interaction",
+ "Removal",
+ "Artifacts Matter"
],
"primary_color": "Blue",
"secondary_color": "Green",
"example_commanders": [
+ "The War Doctor",
+ "King of the Oathbreakers",
+ "Cyclonus, the Saboteur // Cyclonus, Cybertronian Fighter",
"Taniwha",
- "Niv-Mizzet, Parun - Synergy (Flying)",
- "Avacyn, Angel of Hope - Synergy (Flying)",
- "Old Gnawbone - Synergy (Flying)",
- "Ragavan, Nimble Pilferer - Synergy (Little Fellas)"
+ "Boromir, Warden of the Tower - Synergy (Protection)"
],
"example_cards": [
- "Taniwha",
- "Teferi's Isle",
- "Katabatic Winds",
- "Shimmering Efreet",
- "Ertai's Familiar",
- "Merfolk Raiders",
- "Teferi's Imp",
- "Sandbar Crocodile"
+ "Teferi's Protection",
+ "Clever Concealment",
+ "Talon Gates of Madara",
+ "Slip Out the Back",
+ "March of Swirling Mist",
+ "Ripples of Potential",
+ "Galadriel's Dismissal",
+ "Teferi, Master of Time"
],
"synergy_commanders": [
- "Azusa, Lost but Seeking - Synergy (Little Fellas)"
+ "Avacyn, Angel of Hope - Synergy (Protection)",
+ "Yawgmoth, Thran Physician - Synergy (Protection)",
+ "Sram, Senior Edificer - Synergy (Equipment Matters)",
+ "Kodama of the West Tree - Synergy (Equipment Matters)",
+ "Syr Konrad, the Grim - Synergy (Interaction)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -16946,22 +16840,23 @@
"primary_color": "Red",
"example_commanders": [
"Otharri, Suns' Glory",
+ "Joshua, Phoenix's Dominant // Phoenix, Warden of Fire",
"Syrix, Carrier of the Flame",
"Aurelia, the Warleader - Synergy (Haste)",
- "Yahenni, Undying Partisan - Synergy (Haste)",
- "Kiki-Jiki, Mirror Breaker - Synergy (Haste)"
+ "Yahenni, Undying Partisan - Synergy (Haste)"
],
"example_cards": [
"Otharri, Suns' Glory",
"Aurora Phoenix",
"Phoenix Chick",
"Jaya's Phoenix",
+ "Joshua, Phoenix's Dominant // Phoenix, Warden of Fire",
"Detective's Phoenix",
"Flamewake Phoenix",
- "Everquill Phoenix",
- "Ashcloud Phoenix"
+ "Everquill Phoenix"
],
"synergy_commanders": [
+ "Kiki-Jiki, Mirror Breaker - Synergy (Haste)",
"Niv-Mizzet, Parun - Synergy (Flying)",
"Avacyn, Angel of Hope - Synergy (Flying)",
"Rishkar, Peema Renegade - Synergy (Midrange)"
@@ -16987,7 +16882,7 @@
"Sheoldred, the Apocalypse",
"Elas il-Kor, Sadistic Pilgrim",
"Sheoldred, Whispering One",
- "Elesh Norn, Grand Cenobite"
+ "Etali, Primal Conqueror // Etali, Primal Sickness"
],
"example_cards": [
"Phyrexian Metamorph",
@@ -17022,9 +16917,9 @@
"example_commanders": [
"Baird, Steward of Argive",
"Teysa, Envoy of Ghosts",
+ "Tamiyo, Inquisitive Student // Tamiyo, Seasoned Scholar",
"Sivitri, Dragon Master",
- "Isperia, Supreme Judge",
- "Thantis, the Warweaver"
+ "Isperia, Supreme Judge"
],
"example_cards": [
"Propaganda",
@@ -17360,23 +17255,24 @@
"primary_color": "Black",
"secondary_color": "Green",
"example_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness",
"Skrelv, Defector Mite",
"Skithiryx, the Blight Dragon",
"Fynn, the Fangbearer",
- "Karumonix, the Rat King",
- "Ixhel, Scion of Atraxa"
+ "Karumonix, the Rat King"
],
"example_cards": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness",
"Skrelv, Defector Mite",
"Triumph of the Hordes",
"Vraska, Betrayal's Sting",
"White Sun's Twilight",
"Skrelv's Hive",
"Plague Myr",
- "Grafted Exoskeleton",
- "Vraska's Fall"
+ "Grafted Exoskeleton"
],
"synergy_commanders": [
+ "Ixhel, Scion of Atraxa - Synergy (Toxic)",
"Vishgraz, the Doomhive - Synergy (Mite Kindred)"
],
"popularity_bucket": "Uncommon",
@@ -17647,7 +17543,7 @@
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
"Adeline, Resplendent Cathar - Synergy (Planeswalkers)",
"Vorinclex, Monstrous Raider - Synergy (Planeswalkers)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
@@ -17657,11 +17553,11 @@
"id": "protection",
"theme": "Protection",
"synergies": [
+ "Protection from Color",
"Indestructible",
- "Angel Kindred",
- "Interaction",
- "Knight Kindred",
- "Combat Tricks"
+ "Phasing",
+ "Hexproof",
+ "Shroud"
],
"primary_color": "White",
"secondary_color": "Green",
@@ -17670,28 +17566,69 @@
"Avacyn, Angel of Hope",
"Yawgmoth, Thran Physician",
"Padeem, Consul of Innovation",
- "Yahenni, Undying Partisan"
+ "Skrelv, Defector Mite"
],
"example_cards": [
"Swiftfoot Boots",
"Lightning Greaves",
"Heroic Intervention",
"The One Ring",
+ "Teferi's Protection",
"Boros Charm",
"Flawless Maneuver",
- "Akroma's Will",
- "Mithril Coat"
+ "Akroma's Will"
],
"synergy_commanders": [
+ "Emrakul, the World Anew - Synergy (Protection from Color)",
+ "Éowyn, Fearless Knight - Synergy (Protection from Color)",
+ "Lord of the Nazgûl - Synergy (Protection from Color)",
"Toski, Bearer of Secrets - Synergy (Indestructible)",
"Purphoros, God of the Forge - Synergy (Indestructible)",
- "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Indestructible)",
- "Aurelia, the Warleader - Synergy (Angel Kindred)",
+ "The War Doctor - Synergy (Phasing)"
+ ],
+ "popularity_bucket": "Common",
+ "editorial_quality": "draft",
+ "description": "Builds around Protection leveraging synergies with Indestructible and Angel Kindred."
+ },
+ {
+ "id": "protection-from-color",
+ "theme": "Protection from Color",
+ "synergies": [
+ "Protection",
+ "Enchant",
+ "Interaction",
+ "Auras",
+ "Cleric Kindred"
+ ],
+ "primary_color": "White",
+ "secondary_color": "Green",
+ "example_commanders": [
+ "Emrakul, the World Anew",
+ "Éowyn, Fearless Knight",
+ "Lord of the Nazgûl",
+ "Katilda, Dawnhart Martyr // Katilda's Rising Dawn",
+ "Eight-and-a-Half-Tails"
+ ],
+ "example_cards": [
+ "The One Ring",
+ "Teferi's Protection",
+ "Akroma's Will",
+ "Mother of Runes",
+ "Commander's Plate",
+ "Sword of Feast and Famine",
+ "Sword of Hearth and Home",
+ "Sejiri Shelter // Sejiri Glacier"
+ ],
+ "synergy_commanders": [
+ "Boromir, Warden of the Tower - Synergy (Protection)",
+ "Avacyn, Angel of Hope - Synergy (Protection)",
+ "Yawgmoth, Thran Physician - Synergy (Protection)",
+ "Dorothea, Vengeful Victim // Dorothea's Retribution - Synergy (Enchant)",
"Syr Konrad, the Grim - Synergy (Interaction)"
],
- "popularity_bucket": "Very Common",
+ "popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Builds around Protection leveraging synergies with Ward and Hexproof."
+ "description": "Builds around Protection from Color leveraging synergies with Protection and Enchant."
},
{
"id": "prototype",
@@ -17871,8 +17808,8 @@
"Tatyova, Benthic Druid - Synergy (Landfall)",
"Aesi, Tyrant of Gyre Strait - Synergy (Landfall)",
"Bristly Bill, Spine Sower - Synergy (Landfall)",
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Enchantments Matter)"
],
"popularity_bucket": "Rare",
@@ -18407,8 +18344,8 @@
"For Mirrodin!",
"Equip",
"Equipment",
- "Bracket:TutorNonland",
- "Equipment Matters"
+ "Equipment Matters",
+ "Human Kindred"
],
"primary_color": "White",
"secondary_color": "Red",
@@ -18581,8 +18518,8 @@
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)",
- "Heliod, Sun-Crowned - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
+ "Yahenni, Undying Partisan - Synergy (Counters Matter)"
],
"example_cards": [
"Wren's Run Hydra",
@@ -18670,7 +18607,7 @@
"Alchemist's Assistant"
],
"synergy_commanders": [
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -18706,7 +18643,7 @@
"Valeron Wardens"
],
"synergy_commanders": [
- "Yahenni, Undying Partisan - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -18952,7 +18889,7 @@
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"example_cards": [
"Spider-Punk",
@@ -18965,7 +18902,7 @@
"Wrecking Beast"
],
"synergy_commanders": [
- "Heliod, Sun-Crowned - Synergy (Counters Matter)",
+ "Yahenni, Undying Partisan - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Voltron)"
],
"popularity_bucket": "Rare",
@@ -19231,7 +19168,7 @@
"Piper Wright, Publick Reporter - Synergy (Clue Token)",
"Tivit, Seller of Secrets - Synergy (Investigate)",
"Kellan, Inquisitive Prodigy // Tail the Suspect - Synergy (Investigate)",
- "Old Rutstein - Synergy (Blood Token)"
+ "Edgar, Charmed Groom // Edgar Markov's Coffin - Synergy (Blood Token)"
],
"popularity_bucket": "Common",
"editorial_quality": "draft",
@@ -19343,7 +19280,7 @@
"synergy_commanders": [
"Syr Konrad, the Grim - Synergy (Mill)",
"Emry, Lurker of the Loch - Synergy (Mill)",
- "Rishkar, Peema Renegade - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -19540,7 +19477,7 @@
"Sewer Shambler"
],
"synergy_commanders": [
- "Yahenni, Undying Partisan - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -20103,8 +20040,8 @@
"synergy_commanders": [
"Anim Pakal, Thousandth Moon - Synergy (Soldier Kindred)",
"Thalia, Heretic Cathar - Synergy (Soldier Kindred)",
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
"Tatyova, Benthic Druid - Synergy (Lifegain)"
],
"popularity_bucket": "Rare",
@@ -20149,39 +20086,41 @@
"id": "shroud",
"theme": "Shroud",
"synergies": [
- "Toughness Matters",
- "Big Mana",
- "Counters Matter",
- "Little Fellas"
+ "Protection",
+ "Interaction",
+ "Enchant",
+ "Auras",
+ "Enchantments Matter"
],
"primary_color": "Green",
"secondary_color": "Blue",
"example_commanders": [
+ "Eladamri, Lord of Leaves",
"Multani, Maro-Sorcerer",
"Kodama of the North Tree",
"Autumn Willow",
- "Azusa, Lost but Seeking - Synergy (Toughness Matters)",
- "Sheoldred, the Apocalypse - Synergy (Toughness Matters)"
+ "Boromir, Warden of the Tower - Synergy (Protection)"
],
"example_cards": [
+ "Lightning Greaves",
+ "Sylvan Safekeeper",
+ "Sterling Grove",
+ "Silver Shroud Costume",
+ "Greater Auramancy",
+ "Pemmin's Aura",
"Argothian Enchantress",
- "Wall of Denial",
- "Helix Pinnacle",
- "Inkwell Leviathan",
- "Diplomatic Immunity",
- "Simic Sky Swallower",
- "Multani, Maro-Sorcerer",
- "Neurok Commando"
+ "Scion of Oona"
],
"synergy_commanders": [
- "Vito, Thorn of the Dusk Rose - Synergy (Toughness Matters)",
- "Syr Konrad, the Grim - Synergy (Big Mana)",
- "Etali, Primal Storm - Synergy (Big Mana)",
- "Rishkar, Peema Renegade - Synergy (Counters Matter)"
+ "Avacyn, Angel of Hope - Synergy (Protection)",
+ "Yawgmoth, Thran Physician - Synergy (Protection)",
+ "Syr Konrad, the Grim - Synergy (Interaction)",
+ "Purphoros, God of the Forge - Synergy (Interaction)",
+ "Katilda, Dawnhart Martyr // Katilda's Rising Dawn - Synergy (Enchant)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Builds around Shroud leveraging synergies with Protection and Interaction."
+ "description": "Builds around Shroud leveraging synergies with Toughness Matters and Big Mana."
},
{
"id": "siren-kindred",
@@ -20344,8 +20283,8 @@
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)",
- "Heliod, Sun-Crowned - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
+ "Yahenni, Undying Partisan - Synergy (Counters Matter)"
],
"example_cards": [
"Arcbound Slith",
@@ -21064,8 +21003,8 @@
"Rishkar, Peema Renegade - Synergy (+1/+1 Counters)",
"Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)",
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)",
- "Heliod, Sun-Crowned - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
+ "Yahenni, Undying Partisan - Synergy (Counters Matter)"
],
"example_cards": [
"Spike Feeder",
@@ -21551,7 +21490,7 @@
"Bottomless Vault"
],
"synergy_commanders": [
- "Rishkar, Peema Renegade - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -21655,9 +21594,9 @@
"Lulu, Stern Guardian"
],
"synergy_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
"Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)",
"Kutzil, Malamet Exemplar - Synergy (Stax)",
"Lotho, Corrupt Shirriff - Synergy (Stax)",
"Baral, Chief of Compliance - Synergy (Loot)"
@@ -21733,7 +21672,7 @@
"Shoulder to Shoulder"
],
"synergy_commanders": [
- "Heliod, Sun-Crowned - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -22551,10 +22490,10 @@
"theme": "Toolbox",
"synergies": [
"Entwine",
- "Bracket:TutorNonland",
"Proliferate",
"Citizen Kindred",
- "Removal"
+ "Removal",
+ "Convoke"
],
"primary_color": "Green",
"secondary_color": "White",
@@ -22576,13 +22515,13 @@
"Rakdos Charm"
],
"synergy_commanders": [
- "Magda, Brazen Outlaw - Synergy (Bracket:TutorNonland)",
- "Razaketh, the Foulblooded - Synergy (Bracket:TutorNonland)",
- "Yawgmoth, Thran Physician - Synergy (Proliferate)"
+ "Yawgmoth, Thran Physician - Synergy (Proliferate)",
+ "Tekuthal, Inquiry Dominus - Synergy (Proliferate)",
+ "Peregrin Took - Synergy (Citizen Kindred)"
],
"popularity_bucket": "Common",
"editorial_quality": "draft",
- "description": "Builds around Toolbox leveraging synergies with Entwine and Bracket:TutorNonland."
+ "description": "Builds around Toolbox leveraging synergies with Entwine and Proliferate."
},
{
"id": "topdeck",
@@ -22694,8 +22633,8 @@
"Karumonix, the Rat King"
],
"synergy_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Poison Counters)",
"Skithiryx, the Blight Dragon - Synergy (Poison Counters)",
- "Fynn, the Fangbearer - Synergy (Poison Counters)",
"Yawgmoth, Thran Physician - Synergy (Infect)",
"Vorinclex, Monstrous Raider - Synergy (Infect)",
"Mondrak, Glory Dominus - Synergy (Phyrexian Kindred)"
@@ -22771,7 +22710,7 @@
"Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)",
"Syr Konrad, the Grim - Synergy (Human Kindred)",
"Azusa, Lost but Seeking - Synergy (Human Kindred)",
- "Yahenni, Undying Partisan - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -22861,7 +22800,6 @@
"id": "transmute",
"theme": "Transmute",
"synergies": [
- "Bracket:TutorNonland",
"Toughness Matters",
"Spells Matter",
"Spellslinger"
@@ -22869,11 +22807,11 @@
"primary_color": "Blue",
"secondary_color": "Black",
"example_commanders": [
- "Magda, Brazen Outlaw - Synergy (Bracket:TutorNonland)",
- "Razaketh, the Foulblooded - Synergy (Bracket:TutorNonland)",
- "Varragoth, Bloodsky Sire - Synergy (Bracket:TutorNonland)",
"Azusa, Lost but Seeking - Synergy (Toughness Matters)",
- "Sheoldred, the Apocalypse - Synergy (Toughness Matters)"
+ "Sheoldred, the Apocalypse - Synergy (Toughness Matters)",
+ "Vito, Thorn of the Dusk Rose - Synergy (Toughness Matters)",
+ "Lotho, Corrupt Shirriff - Synergy (Spells Matter)",
+ "Birgi, God of Storytelling // Harnfel, Horn of Bounty - Synergy (Spells Matter)"
],
"example_cards": [
"Muddle the Mixture",
@@ -22886,11 +22824,11 @@
"Shred Memory"
],
"synergy_commanders": [
- "Lotho, Corrupt Shirriff - Synergy (Spells Matter)"
+ "Niv-Mizzet, Parun - Synergy (Spellslinger)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Builds around Transmute leveraging synergies with Bracket:TutorNonland and Toughness Matters."
+ "description": "Builds around Transmute leveraging synergies with Toughness Matters and Spells Matter."
},
{
"id": "treasure",
@@ -23134,14 +23072,14 @@
"synergy_commanders": [
"Adrix and Nev, Twincasters - Synergy (Ward)",
"Miirym, Sentinel Wyrm - Synergy (Ward)",
- "Ulamog, the Defiler - Synergy (Ward)",
+ "Codsworth, Handy Helper - Synergy (Ward)",
"Azusa, Lost but Seeking - Synergy (Toughness Matters)",
"Sheoldred, the Apocalypse - Synergy (Toughness Matters)",
"Kutzil, Malamet Exemplar - Synergy (Stax)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Turtle creatures into play with shared payoffs (e.g., Ward and Protection)."
+ "description": "Focuses on getting a high number of Turtle creatures into play with shared payoffs (e.g., Ward and Toughness Matters)."
},
{
"id": "tyranid-kindred",
@@ -23468,8 +23406,8 @@
"Hellhole Flailer"
],
"synergy_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Yahenni, Undying Partisan - Synergy (Counters Matter)",
- "Heliod, Sun-Crowned - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Voltron)"
],
"popularity_bucket": "Rare",
@@ -23533,9 +23471,9 @@
"Twilight Prophet"
],
"synergy_commanders": [
+ "Edgar, Charmed Groom // Edgar Markov's Coffin - Synergy (Blood Token)",
"Old Rutstein - Synergy (Blood Token)",
"Kamber, the Plunderer - Synergy (Blood Token)",
- "Strefan, Maurer Progenitor - Synergy (Blood Token)",
"Heliod, Sun-Crowned - Synergy (Lifegain Triggers)",
"Emrakul, the World Anew - Synergy (Madness)"
],
@@ -23558,7 +23496,7 @@
"Ojer Pakpatiq, Deepest Epoch // Temple of Cyclical Time - Synergy (Time Counters)",
"The Tenth Doctor - Synergy (Time Counters)",
"Jhoira of the Ghitu - Synergy (Time Counters)",
- "Rishkar, Peema Renegade - Synergy (Counters Matter)"
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)"
],
"example_cards": [
"Dreamtide Whale",
@@ -23571,7 +23509,7 @@
"Chronozoa"
],
"synergy_commanders": [
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
+ "Rishkar, Peema Renegade - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Enchantments Matter)"
],
"popularity_bucket": "Rare",
@@ -23722,9 +23660,9 @@
"secondary_color": "Green",
"example_commanders": [
"Yisan, the Wanderer Bard",
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
"Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)",
"Sram, Senior Edificer - Synergy (Enchantments Matter)"
],
"example_cards": [
@@ -23912,41 +23850,41 @@
"theme": "Ward",
"synergies": [
"Turtle Kindred",
- "Dinosaur Kindred",
- "Dragon Kindred",
- "Stax",
- "Reach"
+ "Protection",
+ "Equipment",
+ "Equip",
+ "Dinosaur Kindred"
],
"primary_color": "Blue",
"secondary_color": "Green",
"example_commanders": [
"Adrix and Nev, Twincasters",
"Miirym, Sentinel Wyrm",
+ "Codsworth, Handy Helper",
"Ulamog, the Defiler",
- "Valgavoth, Terror Eater",
- "Sovereign Okinec Ahau"
+ "Valgavoth, Terror Eater"
],
"example_cards": [
"Roaming Throne",
+ "Flowering of the White Tree",
+ "Brotherhood Regalia",
+ "Innkeeper's Talent",
"Kappa Cannoneer",
"Adrix and Nev, Twincasters",
- "Miirym, Sentinel Wyrm",
- "Bronze Guardian",
- "Ulamog, the Defiler",
- "Hulking Raptor",
- "Valgavoth, Terror Eater"
+ "Thran Power Suit",
+ "Miirym, Sentinel Wyrm"
],
"synergy_commanders": [
"Kogla and Yidaro - Synergy (Turtle Kindred)",
"The Pride of Hull Clade - Synergy (Turtle Kindred)",
"Archelos, Lagoon Mystic - Synergy (Turtle Kindred)",
- "Etali, Primal Storm - Synergy (Dinosaur Kindred)",
- "Ghalta, Primal Hunger - Synergy (Dinosaur Kindred)",
- "Niv-Mizzet, Parun - Synergy (Dragon Kindred)"
+ "Boromir, Warden of the Tower - Synergy (Protection)",
+ "Avacyn, Angel of Hope - Synergy (Protection)",
+ "The Reality Chip - Synergy (Equipment)"
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Builds around Ward leveraging synergies with Turtle Kindred and Protection."
+ "description": "Builds around Ward leveraging synergies with Turtle Kindred and Dinosaur Kindred."
},
{
"id": "warlock-kindred",
@@ -24810,9 +24748,9 @@
"primary_color": "White",
"secondary_color": "Blue",
"example_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)"
+ "Krenko, Tin Street Kingpin - Synergy (Counters Matter)"
],
"example_cards": [
"Dwarven Armorer",
@@ -24836,9 +24774,9 @@
"primary_color": "Red",
"secondary_color": "Black",
"example_commanders": [
+ "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
- "Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
- "Yawgmoth, Thran Physician - Synergy (Counters Matter)"
+ "Krenko, Tin Street Kingpin - Synergy (Counters Matter)"
],
"example_cards": [
"Dwarven Armorer",
@@ -24906,7 +24844,7 @@
"Cantrips": 88,
"Card Draw": 309,
"Combat Tricks": 214,
- "Interaction": 904,
+ "Interaction": 949,
"Unconditional Draw": 133,
"Bending": 5,
"Cost Reduction": 68,
@@ -24932,7 +24870,7 @@
"Sloth Kindred": 3,
"Lands Matter": 169,
"Gargoyle Kindred": 11,
- "Protection": 231,
+ "Protection": 276,
"Griffin Kindred": 43,
"Cleric Kindred": 365,
"Backgrounds Matter": 11,
@@ -24941,7 +24879,8 @@
"Warrior Kindred": 155,
"Control": 221,
"Toolbox": 90,
- "Removal": 406,
+ "Removal": 409,
+ "Protection from Color": 69,
"Aristocrats": 155,
"Haunt": 4,
"Sacrifice Matters": 155,
@@ -24956,7 +24895,6 @@
"Bolster": 13,
"Outlast": 7,
"Enchant": 269,
- "Bracket:TutorNonland": 58,
"Knight Kindred": 237,
"Battle Cry": 5,
"Burn": 216,
@@ -24966,6 +24904,7 @@
"Charge Counters": 11,
"Clones": 40,
"Station": 5,
+ "Indestructible": 98,
"Vampire Kindred": 35,
"Gnome Kindred": 13,
"Angel Kindred": 218,
@@ -24984,6 +24923,7 @@
"First strike": 126,
"Scout Kindred": 54,
"Construct Kindred": 15,
+ "Hexproof": 35,
"Convoke": 25,
"Vehicles": 64,
"Dwarf Kindred": 45,
@@ -25102,11 +25042,10 @@
"Adamant": 3,
"Oil Counters": 3,
"Orc Kindred": 6,
- "Bracket:MassLandDenial": 14,
"Dinosaur Kindred": 29,
"Sliver Kindred": 21,
"Armadillo Kindred": 1,
- "Ward": 12,
+ "Ward": 22,
"Horse Kindred": 11,
"Celebration": 5,
"Mouse Kindred": 13,
@@ -25129,7 +25068,6 @@
"Gold Token": 4,
"Robot Kindred": 21,
"Prototype": 3,
- "Indestructible": 11,
"Counterspells": 22,
"Plot": 4,
"Morph": 23,
@@ -25176,6 +25114,7 @@
"Escalate": 3,
"Splice": 5,
"Hippogriff Kindred": 6,
+ "Phasing": 13,
"Backup": 6,
"Shield Counters": 9,
"Blessing Counters": 1,
@@ -25193,7 +25132,7 @@
"Possum Kindred": 1,
"Assist": 4,
"Horror Kindred": 13,
- "Shroud": 1,
+ "Shroud": 14,
"Unity Counters": 1,
"Licid Kindred": 2,
"Camel Kindred": 5,
@@ -25264,7 +25203,6 @@
"Toy Kindred": 4,
"Sculpture Kindred": 1,
"Prowess": 5,
- "Bracket:GameChanger": 6,
"Coyote Kindred": 1,
"Aftermath": 1,
"Fear": 1,
@@ -25355,7 +25293,6 @@
"Kinship": 2,
"-0/-1 Counters": 1,
"Deserter Kindred": 1,
- "Hexproof": 2,
"Hexproof from": 1,
"Adapt": 1,
"Centaur Kindred": 5,
@@ -25484,18 +25421,19 @@
"Manifest dread": 9,
"Control": 666,
"Counterspells": 348,
- "Interaction": 793,
+ "Interaction": 827,
"Stax": 915,
"Fish Kindred": 43,
"Flash": 169,
- "Ward": 39,
- "Protection": 64,
+ "Ward": 44,
+ "Protection": 88,
+ "Shroud": 24,
"Threshold": 9,
"Historics Matter": 292,
"Legends Matter": 292,
"Noble Kindred": 13,
"Octopus Kindred": 42,
- "Removal": 249,
+ "Removal": 257,
"Creature Tokens": 191,
"Devoid": 34,
"Eldrazi Kindred": 42,
@@ -25525,7 +25463,6 @@
"Cantrips": 192,
"Unconditional Draw": 449,
"Board Wipes": 56,
- "Bracket:MassLandDenial": 8,
"Equipment": 25,
"Reconfigure": 3,
"Charge Counters": 12,
@@ -25591,7 +25528,6 @@
"Partner": 17,
"Partner with": 9,
"Vigilance": 50,
- "Bracket:ExtraTurn": 29,
"Foretell": 13,
"God Kindred": 8,
"Flashback": 29,
@@ -25608,11 +25544,10 @@
"Sagas Matter": 33,
"Age Counters": 27,
"Cumulative upkeep": 20,
- "Bracket:TutorNonland": 58,
"Crab Kindred": 35,
"Dragon Kindred": 45,
"Elder Kindred": 4,
- "Hexproof": 21,
+ "Hexproof": 46,
"Faerie Kindred": 81,
"Mana Dork": 47,
"Morph": 43,
@@ -25698,7 +25633,7 @@
"Craft": 5,
"Net Counters": 2,
"Djinn Kindred": 35,
- "Phasing": 10,
+ "Phasing": 36,
"Converge": 4,
"Hag Kindred": 2,
"Corrupted": 2,
@@ -25710,7 +25645,6 @@
"Assassin Kindred": 7,
"Disguise": 4,
"Landfall": 16,
- "Shroud": 8,
"Spell mastery": 4,
"Demigod Kindred": 1,
"Ki Counters": 2,
@@ -25751,7 +25685,6 @@
"Roll to Visit Your Attractions": 1,
"Aftermath": 1,
"Surge": 6,
- "Bracket:GameChanger": 14,
"Replicate": 10,
"Splice": 9,
"Proliferate": 23,
@@ -25890,7 +25823,7 @@
"Praetor Kindred": 3,
"Experience Counters": 1,
"Exhaust": 6,
- "Indestructible": 3,
+ "Indestructible": 4,
"Kithkin Kindred": 1,
"Flanking": 1,
"Minotaur Kindred": 1,
@@ -26030,7 +25963,7 @@
"Token Creation": 415,
"Tokens Matter": 416,
"Combat Tricks": 174,
- "Interaction": 805,
+ "Interaction": 808,
"Midrange": 69,
"Horror Kindred": 184,
"Basic landcycling": 2,
@@ -26059,7 +25992,8 @@
"Trample": 54,
"Specter Kindred": 21,
"Centaur Kindred": 3,
- "Protection": 51,
+ "Indestructible": 32,
+ "Protection": 54,
"Warrior Kindred": 168,
"Intimidate": 13,
"Spirit Kindred": 145,
@@ -26094,7 +26028,6 @@
"Flash": 55,
"Phyrexian Kindred": 165,
"Shapeshifter Kindred": 11,
- "Bracket:GameChanger": 10,
"Topdeck": 171,
"Crocodile Kindred": 12,
"Druid Kindred": 6,
@@ -26113,7 +26046,7 @@
"Ogre Kindred": 35,
"Sacrifice to Draw": 85,
"Constellation": 6,
- "Removal": 480,
+ "Removal": 481,
"Mercenary Kindred": 43,
"Heroic": 4,
"Backgrounds Matter": 12,
@@ -26133,7 +26066,7 @@
"Disguise": 4,
"Madness": 29,
"Void": 10,
- "Ward": 16,
+ "Ward": 17,
"Warp": 14,
"Skeleton Kindred": 66,
"Charge Counters": 9,
@@ -26190,7 +26123,6 @@
"Avatar Kindred": 18,
"Fear": 31,
"Mobilize": 3,
- "Bracket:TutorNonland": 88,
"Elf Kindred": 42,
"Azra Kindred": 5,
"Ninja Kindred": 17,
@@ -26213,7 +26145,6 @@
"Dog Kindred": 17,
"Myriad": 2,
"Amass": 19,
- "Indestructible": 9,
"Suspect": 5,
"Wurm Kindred": 9,
"\\+2/\\+2 Counters": 2,
@@ -26298,7 +26229,6 @@
"Read Ahead": 2,
"Sagas Matter": 29,
"Transmute": 5,
- "Bracket:MassLandDenial": 4,
"Overload": 2,
"Encore": 5,
"Freerunning": 6,
@@ -26487,6 +26417,7 @@
"Ki Counters": 2,
"Boar Kindred": 3,
"Exhaust": 1,
+ "Phasing": 2,
"Soul Counters": 4,
"Monstrosity": 3,
"Demonstrate": 1,
@@ -26513,6 +26444,7 @@
"Plant Kindred": 2,
"Manticore Kindred": 1,
"Hit Counters": 2,
+ "Protection from Color": 2,
"Cipher": 5,
"Hour Counters": 1,
"Processor Kindred": 2,
@@ -26569,7 +26501,6 @@
"Spike Kindred": 1,
"Mite Kindred": 1,
"Ripple": 1,
- "Bracket:ExtraTurn": 1,
"Tempting offer": 1,
"Prey Counters": 1,
"Firebending": 1,
@@ -26605,7 +26536,7 @@
"Combat Matters": 1406,
"Combat Tricks": 160,
"Discard Matters": 303,
- "Interaction": 631,
+ "Interaction": 630,
"Madness": 18,
"Mill": 341,
"Reanimate": 261,
@@ -26691,7 +26622,7 @@
"Equipment Matters": 141,
"Samurai Kindred": 20,
"Shaman Kindred": 175,
- "Protection": 20,
+ "Protection": 19,
"Conditional Draw": 42,
"Phyrexian Kindred": 44,
"Ally Kindred": 19,
@@ -26743,7 +26674,6 @@
"Incarnation Kindred": 3,
"Pirate Kindred": 53,
"Citizen Kindred": 14,
- "Bracket:MassLandDenial": 28,
"Spellshaper Kindred": 12,
"Ox Kindred": 7,
"Cat Kindred": 31,
@@ -26865,7 +26795,7 @@
"Cumulative upkeep": 7,
"Shark Kindred": 4,
"Mouse Kindred": 9,
- "Indestructible": 6,
+ "Indestructible": 8,
"Discover": 9,
"Card Selection": 2,
"Explore": 1,
@@ -26905,7 +26835,6 @@
"Rev Counters": 1,
"Luck Counters": 1,
"Loyalty Counters": 6,
- "Bracket:TutorNonland": 24,
"Champion": 3,
"Shapeshifter Kindred": 5,
"Harmonize": 3,
@@ -26927,6 +26856,7 @@
"Encore": 4,
"Domain": 6,
"Multikicker": 4,
+ "Ward": 4,
"Manticore Kindred": 9,
"Treefolk Kindred": 1,
"Licid Kindred": 2,
@@ -26954,7 +26884,6 @@
"Charge Counters": 17,
"Station": 4,
"Retrace": 5,
- "Bracket:GameChanger": 4,
"Melee": 2,
"Descent Counters": 1,
"Desertwalk": 1,
@@ -26979,7 +26908,6 @@
"Quest Counters": 5,
"\\+0/\\+1 Counters": 1,
"\\+2/\\+2 Counters": 1,
- "Ward": 3,
"Storage Counters": 2,
"Overload": 8,
"Eternalize": 1,
@@ -27013,7 +26941,6 @@
"Ape Kindred": 7,
"Manifest": 4,
"Chroma": 3,
- "Bracket:ExtraTurn": 3,
"Bending": 5,
"Firebending": 5,
"Snake Kindred": 1,
@@ -27045,6 +26972,7 @@
"Exalted": 1,
"Islandwalk": 1,
"Battle Cry": 5,
+ "Protection from Color": 1,
"Troll Kindred": 3,
"Meld": 1,
"Aim Counters": 1,
@@ -27170,7 +27098,7 @@
"Token Creation": 520,
"Tokens Matter": 529,
"Artifacts Matter": 449,
- "Interaction": 538,
+ "Interaction": 549,
"Little Fellas": 1380,
"Mutant Kindred": 27,
"Ravenous": 7,
@@ -27259,19 +27187,21 @@
"Elephant Kindred": 43,
"Cycling": 52,
"Discard Matters": 87,
+ "Indestructible": 41,
"Loot": 52,
- "Protection": 96,
+ "Protection": 105,
"Vehicles": 25,
"Revolt": 6,
"Scout Kindred": 97,
"Stax": 271,
+ "Hexproof": 67,
"Faerie Kindred": 13,
"Soldier Kindred": 37,
"Mount Kindred": 14,
"Saddle": 9,
"Troll Kindred": 29,
"Crocodile Kindred": 11,
- "Shroud": 21,
+ "Shroud": 28,
"Brushwagg Kindred": 4,
"Exile Matters": 87,
"Outlaw Kindred": 31,
@@ -27288,13 +27218,12 @@
"Wolf Kindred": 80,
"Landwalk": 58,
"Swampwalk": 10,
- "Bracket:TutorNonland": 65,
"Collect evidence": 6,
"Partner": 13,
"Treasure": 26,
"Treasure Token": 25,
"Turtle Kindred": 12,
- "Ward": 25,
+ "Ward": 31,
"Elder Kindred": 3,
"Flying": 48,
"Mana Rock": 16,
@@ -27321,7 +27250,6 @@
"Clones": 41,
"Populate": 6,
"Sloth Kindred": 3,
- "Hexproof": 33,
"Defender": 40,
"Boar Kindred": 31,
"Landfall": 68,
@@ -27477,7 +27405,6 @@
"Champion": 3,
"Assist": 2,
"Acorn Counters": 1,
- "Bracket:MassLandDenial": 6,
"Backup": 6,
"Fateful hour": 2,
"Cockatrice Kindred": 1,
@@ -27503,7 +27430,6 @@
"Suspend": 12,
"Time Counters": 14,
"Rampage": 3,
- "Bracket:GameChanger": 8,
"Fabricate": 4,
"Disguise": 7,
"Horror Kindred": 27,
@@ -27522,6 +27448,7 @@
"Pilot Kindred": 4,
"Sand Kindred": 2,
"Egg Kindred": 2,
+ "Protection from Color": 4,
"Soulbond": 8,
"Employee Kindred": 3,
"Robot Kindred": 5,
@@ -27604,7 +27531,6 @@
"Undying": 4,
"Rat Kindred": 1,
"Efreet Kindred": 2,
- "Indestructible": 9,
"Parley": 3,
"Harmony Counters": 1,
"Orc Kindred": 1,
@@ -27671,7 +27597,6 @@
"Hag Kindred": 1,
"Monkey Kindred": 4,
"Undaunted": 1,
- "Bracket:ExtraTurn": 1,
"Map Token": 2,
"Multiple Copies": 1,
"Slime Against Humanity": 1,
@@ -27713,12 +27638,12 @@
"generated_from": "merge (analytics + curated YAML + whitelist)",
"metadata_info": {
"mode": "merge",
- "generated_at": "2025-10-09T00:37:26",
+ "generated_at": "2025-10-09T23:48:28",
"curated_yaml_files": 735,
"synergy_cap": 5,
"inference": "pmi",
"version": "phase-b-merge-v1",
- "catalog_hash": "9f938ea43a438ed3d924bdb971bf4efa34b80fa3728877878729db50c9afa2fd"
+ "catalog_hash": "fd3931305d6c86535e5e9c5ec4e95b5a05bc7b125b9dd6557600a4b3cf610ce4"
},
"description_fallback_summary": null
}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index f2c6106..5470410 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -98,7 +98,13 @@ services:
WEB_AUTO_SETUP: "1" # 1=auto-run setup/tagging when needed
WEB_AUTO_REFRESH_DAYS: "7" # Refresh cards.csv if older than N days; 0=never
WEB_TAG_PARALLEL: "1" # 1=parallelize tagging
- WEB_TAG_WORKERS: "4" # Worker count when parallel tagging
+ WEB_TAG_WORKERS: "8" # Worker count when parallel tagging
+
+ # Tagging Refinement Feature Flags
+ TAG_NORMALIZE_KEYWORDS: "1" # 1=normalize keywords & filter specialty mechanics (recommended)
+ TAG_PROTECTION_GRANTS: "1" # 1=Protection tag only for cards granting shields (recommended)
+ TAG_METADATA_SPLIT: "1" # 1=separate metadata tags from themes in CSVs (recommended)
+
THEME_CATALOG_MODE: "merge" # Use merged Phase B catalog builder (with YAML export)
THEME_YAML_FAST_SKIP: "0" # 1=allow skipping per-theme YAML on fast path (rare; default always export)
# Live YAML scan interval in seconds for change detection (dev convenience)
diff --git a/dockerhub-docker-compose.yml b/dockerhub-docker-compose.yml
index 47b2c7f..b2b1731 100644
--- a/dockerhub-docker-compose.yml
+++ b/dockerhub-docker-compose.yml
@@ -101,6 +101,12 @@ services:
WEB_AUTO_REFRESH_DAYS: "7" # Refresh cards.csv if older than N days; 0=never
WEB_TAG_PARALLEL: "1" # 1=parallelize tagging
WEB_TAG_WORKERS: "4" # Worker count when parallel tagging
+
+ # Tagging Refinement Feature Flags
+ TAG_NORMALIZE_KEYWORDS: "1" # 1=normalize keywords & filter specialty mechanics (recommended)
+ TAG_PROTECTION_GRANTS: "1" # 1=Protection tag only for cards granting shields (recommended)
+ TAG_METADATA_SPLIT: "1" # 1=separate metadata tags from themes in CSVs (recommended)
+
THEME_CATALOG_MODE: "merge" # Use merged Phase B catalog builder (with YAML export)
THEME_YAML_FAST_SKIP: "0" # 1=allow skipping per-theme YAML on fast path (rare; default always export)
# Live YAML scan interval in seconds for change detection (dev convenience)