From 06d8796316a6249e6652dfa524efe1cf08d69ef5 Mon Sep 17 00:00:00 2001 From: matt Date: Wed, 8 Oct 2025 20:59:51 -0700 Subject: [PATCH 1/4] feat: add keyword normalization and protection grant detection, fix template syntax and polling issues --- CHANGELOG.md | 19 +- README.md | 2 +- RELEASE_NOTES_TEMPLATE.md | 29 +- code/scripts/audit_protection_full_v2.py | 203 ++++ code/settings.py | 16 +- code/tagging/protection_grant_detection.py | 493 ++++++++++ code/tagging/tag_constants.py | 85 ++ code/tagging/tag_utils.py | 75 +- code/tagging/tagger.py | 92 +- code/tests/test_keyword_normalization.py | 182 ++++ code/tests/test_protection_grant_detection.py | 169 ++++ code/web/routes/build.py | 2 +- code/web/services/orchestrator.py | 12 +- code/web/templates/base.html | 3 +- code/web/templates/build/_step5.html | 5 +- code/web/templates/setup/running.html | 3 +- config/themes/theme_list.json | 913 +++++++----------- 17 files changed, 1692 insertions(+), 611 deletions(-) create mode 100644 code/scripts/audit_protection_full_v2.py create mode 100644 code/tagging/protection_grant_detection.py create mode 100644 code/tests/test_keyword_normalization.py create mode 100644 code/tests/test_protection_grant_detection.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 954e339..b5f4ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,27 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Summary -- _No unreleased changes yet_ +- 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 ### Added -- _None_ +- 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") ### Changed -- _None_ +- 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) ### Fixed -- _None_ +- 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 ## [2.5.2] - 2025-10-08 ### Summary diff --git a/README.md b/README.md index ee62409..7ad729e 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Execute saved configs without manual input. ### Initial Setup Refresh data and caches when formats shift. -- Runs card downloads, CSV regeneration, tagging, and commander catalog rebuilds. +- Runs card downloads, CSV regeneration, smart tagging (keywords + protection grants), and commander catalog rebuilds. - Controlled by `SHOW_SETUP=1` (on by default in compose). - Force a rebuild manually: ```powershell diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 1c99b55..d35313a 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,10 +1,29 @@ -# MTG Python Deckbuilder ${VERSION} +# 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} ### Summary -- 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. +- 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 ### Added - Skeleton placeholders accept `data-skeleton-label` microcopy and only surface after ~400 ms across the build wizard, stage navigator, and alternatives panel. diff --git a/code/scripts/audit_protection_full_v2.py b/code/scripts/audit_protection_full_v2.py new file mode 100644 index 0000000..a10d415 --- /dev/null +++ b/code/scripts/audit_protection_full_v2.py @@ -0,0 +1,203 @@ +""" +Full audit of Protection-tagged cards with kindred metadata support (M2 Phase 2). + +Created: October 8, 2025 +Purpose: Audit and validate Protection tag precision after implementing grant detection. + Can be re-run periodically to check tagging quality. + +This script audits ALL Protection-tagged cards and categorizes them: +- Grant: Gives broad protection to other permanents YOU control +- Kindred: Gives protection to specific creature types (metadata tags) +- Mixed: Both broad and kindred/inherent +- Inherent: Only has protection itself +- ConditionalSelf: Only conditionally grants to itself +- Opponent: Grants to opponent's permanents +- Neither: False positive + +Outputs: +- m2_audit_v2.json: Full analysis with summary +- m2_audit_v2_grant.csv: Cards for main Protection tag +- m2_audit_v2_kindred.csv: Cards for kindred metadata tags +- m2_audit_v2_mixed.csv: Cards with both broad and kindred grants +- m2_audit_v2_conditional.csv: Conditional self-grants (exclude) +- m2_audit_v2_inherent.csv: Inherent protection only (exclude) +- m2_audit_v2_opponent.csv: Opponent grants (exclude) +- m2_audit_v2_neither.csv: False positives (exclude) +- m2_audit_v2_all.csv: All cards combined +""" + +import sys +from pathlib import Path +import pandas as pd +import json + +# Add project root to path +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +from code.tagging.protection_grant_detection import ( + categorize_protection_card, + get_kindred_protection_tags, + is_granting_protection, +) + +def load_all_cards(): + """Load all cards from color/identity CSV files.""" + csv_dir = project_root / 'csv_files' + + # Get all color/identity CSVs (not the raw cards.csv) + csv_files = list(csv_dir.glob('*_cards.csv')) + csv_files = [f for f in csv_files if f.stem not in ['cards', 'testdata']] + + all_cards = [] + for csv_file in csv_files: + try: + df = pd.read_csv(csv_file) + all_cards.append(df) + except Exception as e: + print(f"Warning: Could not load {csv_file.name}: {e}") + + # Combine all DataFrames + combined = pd.concat(all_cards, ignore_index=True) + + # Drop duplicates (cards appear in multiple color files) + combined = combined.drop_duplicates(subset=['name'], keep='first') + + return combined + +def audit_all_protection_cards(): + """Audit all Protection-tagged cards.""" + print("Loading all cards...") + df = load_all_cards() + + print(f"Total cards loaded: {len(df)}") + + # Filter to Protection-tagged cards (column is 'themeTags' in color CSVs) + df_prot = df[df['themeTags'].str.contains('Protection', case=False, na=False)].copy() + + print(f"Protection-tagged cards: {len(df_prot)}") + + # Categorize each card + categories = [] + grants_list = [] + kindred_tags_list = [] + + for idx, row in df_prot.iterrows(): + name = row['name'] + text = str(row.get('text', '')).replace('\\n', '\n') # Convert escaped newlines to real newlines + keywords = str(row.get('keywords', '')) + card_type = str(row.get('type', '')) + + # Categorize with kindred exclusion enabled + category = categorize_protection_card(name, text, keywords, card_type, exclude_kindred=True) + + # Check if it grants broadly + grants_broad = is_granting_protection(text, keywords, exclude_kindred=True) + + # Get kindred tags + kindred_tags = get_kindred_protection_tags(text) + + categories.append(category) + grants_list.append(grants_broad) + kindred_tags_list.append(', '.join(sorted(kindred_tags)) if kindred_tags else '') + + df_prot['category'] = categories + df_prot['grants_broad'] = grants_list + df_prot['kindred_tags'] = kindred_tags_list + + # Generate summary (convert numpy types to native Python for JSON serialization) + summary = { + 'total': int(len(df_prot)), + 'categories': {k: int(v) for k, v in df_prot['category'].value_counts().to_dict().items()}, + 'grants_broad_count': int(df_prot['grants_broad'].sum()), + 'kindred_cards_count': int((df_prot['kindred_tags'] != '').sum()), + } + + # Calculate keep vs remove + keep_categories = {'Grant', 'Mixed'} + kindred_only = df_prot[df_prot['category'] == 'Kindred'] + keep_count = len(df_prot[df_prot['category'].isin(keep_categories)]) + remove_count = len(df_prot[~df_prot['category'].isin(keep_categories | {'Kindred'})]) + + summary['keep_main_tag'] = keep_count + summary['kindred_metadata'] = len(kindred_only) + summary['remove'] = remove_count + summary['precision_estimate'] = round((keep_count / len(df_prot)) * 100, 1) if len(df_prot) > 0 else 0 + + # Print summary + print(f"\n{'='*60}") + print("AUDIT SUMMARY") + print(f"{'='*60}") + print(f"Total Protection-tagged cards: {summary['total']}") + print(f"\nCategories:") + for cat, count in sorted(summary['categories'].items()): + pct = (count / summary['total']) * 100 + print(f" {cat:20s} {count:4d} ({pct:5.1f}%)") + + print(f"\n{'='*60}") + print(f"Main Protection tag: {keep_count:4d} ({keep_count/len(df_prot)*100:5.1f}%)") + print(f"Kindred metadata only: {len(kindred_only):4d} ({len(kindred_only)/len(df_prot)*100:5.1f}%)") + print(f"Remove: {remove_count:4d} ({remove_count/len(df_prot)*100:5.1f}%)") + print(f"{'='*60}") + print(f"Precision estimate: {summary['precision_estimate']}%") + print(f"{'='*60}\n") + + # Export results + output_dir = project_root / 'logs' / 'roadmaps' / 'source' / 'tagging_refinement' + output_dir.mkdir(parents=True, exist_ok=True) + + # Export JSON summary + with open(output_dir / 'm2_audit_v2.json', 'w') as f: + json.dump({ + 'summary': summary, + 'cards': df_prot[['name', 'type', 'category', 'grants_broad', 'kindred_tags', 'keywords', 'text']].to_dict(orient='records') + }, f, indent=2) + + # Export CSVs by category + export_cols = ['name', 'type', 'category', 'grants_broad', 'kindred_tags', 'keywords', 'text'] + + # Grant category + df_grant = df_prot[df_prot['category'] == 'Grant'] + df_grant[export_cols].to_csv(output_dir / 'm2_audit_v2_grant.csv', index=False) + print(f"Exported {len(df_grant)} Grant cards to m2_audit_v2_grant.csv") + + # Kindred category + df_kindred = df_prot[df_prot['category'] == 'Kindred'] + df_kindred[export_cols].to_csv(output_dir / 'm2_audit_v2_kindred.csv', index=False) + print(f"Exported {len(df_kindred)} Kindred cards to m2_audit_v2_kindred.csv") + + # Mixed category + df_mixed = df_prot[df_prot['category'] == 'Mixed'] + df_mixed[export_cols].to_csv(output_dir / 'm2_audit_v2_mixed.csv', index=False) + print(f"Exported {len(df_mixed)} Mixed cards to m2_audit_v2_mixed.csv") + + # ConditionalSelf category + df_conditional = df_prot[df_prot['category'] == 'ConditionalSelf'] + df_conditional[export_cols].to_csv(output_dir / 'm2_audit_v2_conditional.csv', index=False) + print(f"Exported {len(df_conditional)} ConditionalSelf cards to m2_audit_v2_conditional.csv") + + # Inherent category + df_inherent = df_prot[df_prot['category'] == 'Inherent'] + df_inherent[export_cols].to_csv(output_dir / 'm2_audit_v2_inherent.csv', index=False) + print(f"Exported {len(df_inherent)} Inherent cards to m2_audit_v2_inherent.csv") + + # Opponent category + df_opponent = df_prot[df_prot['category'] == 'Opponent'] + df_opponent[export_cols].to_csv(output_dir / 'm2_audit_v2_opponent.csv', index=False) + print(f"Exported {len(df_opponent)} Opponent cards to m2_audit_v2_opponent.csv") + + # Neither category + df_neither = df_prot[df_prot['category'] == 'Neither'] + df_neither[export_cols].to_csv(output_dir / 'm2_audit_v2_neither.csv', index=False) + print(f"Exported {len(df_neither)} Neither cards to m2_audit_v2_neither.csv") + + # All cards + df_prot[export_cols].to_csv(output_dir / 'm2_audit_v2_all.csv', index=False) + print(f"Exported {len(df_prot)} total cards to m2_audit_v2_all.csv") + + print(f"\nAll files saved to: {output_dir}") + + return df_prot, summary + +if __name__ == '__main__': + df_results, summary = audit_all_protection_cards() diff --git a/code/settings.py b/code/settings.py index 0807378..5731031 100644 --- a/code/settings.py +++ b/code/settings.py @@ -1,6 +1,7 @@ from __future__ import annotations # Standard library imports +import os from typing import Dict, List, Optional # ---------------------------------------------------------------------------------- @@ -98,4 +99,17 @@ CSV_DIRECTORY: str = 'csv_files' FILL_NA_COLUMNS: Dict[str, Optional[str]] = { 'colorIdentity': 'Colorless', # Default color identity for cards without one 'faceName': None # Use card's name column value when face name is not available -} \ No newline at end of file +} + +# ---------------------------------------------------------------------------------- +# TAGGING REFINEMENT FEATURE FLAGS (M1-M3) +# ---------------------------------------------------------------------------------- + +# M1: Enable keyword normalization and singleton pruning +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') + +# 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 diff --git a/code/tagging/protection_grant_detection.py b/code/tagging/protection_grant_detection.py new file mode 100644 index 0000000..dca37b4 --- /dev/null +++ b/code/tagging/protection_grant_detection.py @@ -0,0 +1,493 @@ +""" +Protection grant detection implementation for M2. + +This module provides helpers to distinguish cards that grant protection effects +from cards that have inherent protection effects. + +Usage in tagger.py: + from code.tagging.protection_grant_detection import is_granting_protection + + if is_granting_protection(text, keywords): + # Tag as Protection +""" + +import re +from typing import Set, List, Pattern + +from code.tagging.tag_constants import CREATURE_TYPES + + +# Pre-compile kindred detection patterns at module load for performance +# Pattern: (compiled_regex, tag_name_template) +KINDRED_PATTERNS: List[tuple[Pattern, str]] = [] + +def _init_kindred_patterns(): + """Initialize pre-compiled kindred patterns for all creature types.""" + global KINDRED_PATTERNS + if KINDRED_PATTERNS: + return # Already initialized + + for creature_type in CREATURE_TYPES: + creature_lower = creature_type.lower() + creature_escaped = re.escape(creature_lower) + tag_name = f"{creature_type}s Gain Protection" + + # Create 3 patterns per type + patterns_to_compile = [ + (rf'\bother {creature_escaped}s?\b.*\b(have|gain)\b', tag_name), + (rf'\b{creature_escaped} creatures?\b.*\b(have|gain)\b', tag_name), + (rf'\btarget {creature_escaped}\b.*\bgains?\b', tag_name), + ] + + for pattern_str, tag in patterns_to_compile: + try: + compiled = re.compile(pattern_str, re.IGNORECASE) + KINDRED_PATTERNS.append((compiled, tag)) + except re.error: + # Skip patterns that fail to compile + pass + + +# Grant verb patterns - cards that give protection to other permanents +# These patterns look for grant verbs that affect OTHER permanents, not self +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 +] + +# Self-reference patterns that should NOT count as granting +# Reminder text and keyword lines only +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 +] + +# Conditional self-grant patterns - activated/triggered abilities that grant to self +CONDITIONAL_SELF_GRANT_PATTERNS = [ + # Activated abilities + r'\{[^}]*\}.*:.*\bthis (creature|permanent|artifact|enchantment)\b.*\bgain[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'discard.*:.*\bthis (creature|permanent|artifact|enchantment)\b.*\bgain[s]?\b', + r'\{t\}.*:.*\bthis (creature|permanent|artifact|enchantment)\b.*\bgain[s]?\b', + r'sacrifice.*:.*\bthis (creature|permanent|artifact|enchantment)\b.*\bgain[s]?\b', + r'pay.*life.*:.*\bthis (creature|permanent|artifact|enchantment)\b.*\bgain[s]?\b', + # Triggered abilities that grant to self only + r'whenever.*\b(this creature|this permanent|it)\b.*\bgain[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'whenever you (cast|play|attack|cycle|discard|commit).*\b(this creature|this permanent|it)\b.*\bgain[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'at the beginning.*\b(this creature|this permanent|it)\b.*\bgain[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'whenever.*\b(this creature|this permanent)\b (attacks|enters|becomes).*\b(this creature|this permanent|it)\b.*\bgain[s]?\b', + # Named self-references (e.g., "Pristine Skywise gains") + r'whenever you cast.*[A-Z][a-z]+.*gains.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'whenever you.*[A-Z][a-z]+.*gains.*\b(hexproof|shroud|indestructible|ward|protection)\b', + # Static conditional abilities (as long as, if you control X) + r'as long as.*\b(this creature|this permanent|it|has)\b.*(has|gains?).*\b(hexproof|shroud|indestructible|ward|protection)\b', +] + +# Mass grant patterns - affects multiple creatures YOU control +MASS_GRANT_PATTERNS = [ + r'creatures you control (have|gain|get)', + r'other .* you control (have|gain|get)', + r'(artifacts?|enchantments?|permanents?) you control (have|gain|get)', # Artifacts you control have... + r'other (creatures?|artifacts?|enchantments?) (have|gain|get)', # Other creatures have... + r'all (creatures?|slivers?|permanents?) (have|gain|get)', # All creatures/slivers have... +] + +# Targeted grant patterns - must specify "you control" +TARGETED_GRANT_PATTERNS = [ + r'target .* you control (gains?|gets?|has)', + r'equipped creature (gains?|gets?|has)', + r'enchanted creature (gains?|gets?|has)', +] + +# Exclusion patterns - cards that remove or prevent protection +EXCLUSION_PATTERNS = [ + r"can't have (hexproof|indestructible|ward|shroud)", + r"lose[s]? (hexproof|indestructible|ward|shroud|protection)", + r"without (hexproof|indestructible|ward|shroud)", + r"protection from.*can't", +] + +# Opponent grant patterns - grants to opponent's permanents (EXCLUDE these) +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" +] + +# Kindred-specific grant patterns for metadata tagging +KINDRED_GRANT_PATTERNS = { + 'Knights Gain Protection': [ + r'knight[s]? you control.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'other knight[s]?.*\b(hexproof|shroud|indestructible|ward|protection)\b', + ], + 'Merfolk Gain Protection': [ + r'merfolk you control.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'other merfolk.*\b(hexproof|shroud|indestructible|ward|protection)\b', + ], + 'Zombies Gain Protection': [ + r'zombie[s]? you control.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'other zombie[s]?.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'target.*zombie.*\bgain[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection)\b', + ], + 'Vampires Gain Protection': [ + r'vampire[s]? you control.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'other vampire[s]?.*\b(hexproof|shroud|indestructible|ward|protection)\b', + ], + 'Elves Gain Protection': [ + r'el(f|ves) you control.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'other el(f|ves).*\b(hexproof|shroud|indestructible|ward|protection)\b', + ], + 'Dragons Gain Protection': [ + r'dragon[s]? you control.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'other dragon[s]?.*\b(hexproof|shroud|indestructible|ward|protection)\b', + ], + 'Goblins Gain Protection': [ + r'goblin[s]? you control.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'other goblin[s]?.*\b(hexproof|shroud|indestructible|ward|protection)\b', + ], + 'Slivers Gain Protection': [ + r'sliver[s]? you control.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'all sliver[s]?.*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'other sliver[s]?.*\b(hexproof|shroud|indestructible|ward|protection)\b', + ], + 'Artifacts Gain Protection': [ + r'artifact[s]? you control (have|gain).*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'other artifact[s]? (have|gain).*\b(hexproof|shroud|indestructible|ward|protection)\b', + ], + 'Enchantments Gain Protection': [ + r'enchantment[s]? you control (have|gain).*\b(hexproof|shroud|indestructible|ward|protection)\b', + r'other enchantment[s]? (have|gain).*\b(hexproof|shroud|indestructible|ward|protection)\b', + ], +} + +# Protection keyword patterns for inherent check +PROTECTION_KEYWORDS = { + 'hexproof', + 'shroud', + 'indestructible', + 'ward', + 'protection from', + 'protection', +} + + +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". + + Uses both predefined patterns and dynamic creature type detection. + """ + if not text: + return set() + + # Initialize pre-compiled patterns if needed + _init_kindred_patterns() + + 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']): + return tags + + # 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) + # Don't break - a card could grant to multiple creature types + + return tags + + +def is_opponent_grant(text: str) -> bool: + """ + Check if card grants protection to opponent's permanents or all permanents. + + Returns True if this grants to opponents (should be excluded from Protection tag). + """ + if not text: + return False + + text_lower = text.lower() + + # Check for opponent grant patterns + 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 + # 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]: + return True + + return False + + +def has_conditional_self_grant(text: str) -> bool: + """ + Check if card has any conditional self-grant patterns. + This does NOT check if it ALSO grants to others. + """ + if not text: + return False + + text_lower = text.lower() + + # Check for conditional self-grant patterns (activated/triggered abilities) + for pattern in CONDITIONAL_SELF_GRANT_PATTERNS: + if re.search(pattern, text_lower, re.IGNORECASE): + return True + + return False + + +def is_conditional_self_grant(text: str) -> bool: + """ + Check if card only conditionally grants protection to itself. + + Examples: + - "{B}, Discard a card: This creature gains hexproof until end of turn." + - "Whenever you cast a noncreature spell, untap this creature. It gains protection..." + - "Whenever this creature attacks, it gains indestructible until end of turn." + + These should be excluded as they don't provide protection to OTHER permanents. + """ + if not text: + return False + + text_lower = text.lower() + + # Check if it has conditional self-grant patterns + found_conditional_self = has_conditional_self_grant(text) + + if not found_conditional_self: + return False + + # If we found a conditional self-grant, check if there's ALSO a grant to others + # Look for patterns that grant to creatures besides itself + has_other_grant = any(re.search(pattern, text_lower, re.IGNORECASE) for pattern in [ + r'other creatures', + r'creatures you control (have|gain)', + r'target (creature|permanent) you control gains', + r'another target (creature|permanent)', + r'equipped creature (has|gains)', + r'enchanted creature (has|gains)', + r'target legendary', + r'permanents you control gain', + ]) + + # Return True only if it's ONLY conditional self-grants (no other grants) + return not has_other_grant + + +def is_granting_protection(text: str, keywords: str, exclude_kindred: bool = False) -> bool: + """ + Determine if a card grants protection effects to other permanents. + + Returns True if the card gives/grants protection to other cards unconditionally. + Returns False if: + - Card only has inherent protection + - Card only conditionally grants to itself + - Card grants to opponent's permanents + - Card grants only to specific kindred types (when exclude_kindred=True) + - Card creates tokens with protection (not granting to existing permanents) + - Card only modifies non-protection stats of other permanents + + Args: + text: Card text to analyze + keywords: Card keywords (comma-separated) + exclude_kindred: If True, exclude kindred-specific grants + + Returns: + True if card grants broad protection, False otherwise + """ + if not text: + return False + + text_lower = text.lower() + + # EXCLUDE: Opponent grants + if is_opponent_grant(text): + return False + + # EXCLUDE: Conditional self-grants only + if is_conditional_self_grant(text): + return False + + # EXCLUDE: Cards that remove protection + for pattern in EXCLUSION_PATTERNS: + if re.search(pattern, text_lower, re.IGNORECASE): + return False + + # EXCLUDE: Token creation with protection (not granting to existing permanents) + if re.search(r'create.*token.*with.*(hexproof|shroud|indestructible|ward|protection)', text_lower, re.IGNORECASE): + # Check if there's ALSO granting to other permanents + has_grant_to_others = any(re.search(pattern, text_lower, re.IGNORECASE) for pattern in MASS_GRANT_PATTERNS) + if not has_grant_to_others: + return False + + # EXCLUDE: Kindred-specific grants if requested + if exclude_kindred: + kindred_tags = get_kindred_protection_tags(text) + if kindred_tags: + # If we detected kindred tags, check if there's ALSO a non-kindred grant + # Look for grant patterns that explicitly grant to ALL creatures/permanents broadly + has_broad_grant = False + + # Patterns that indicate truly broad grants (not type-specific) + broad_only_patterns = [ + r'\bcreatures you control (have|gain)\b(?!.*(knight|merfolk|zombie|elf|dragon|goblin|sliver))', # Only if not followed by type + r'\bpermanents you control (have|gain)\b', + r'\beach (creature|permanent) you control', + r'\ball (creatures?|permanents?)', + ] + + for pattern in broad_only_patterns: + if re.search(pattern, text_lower, re.IGNORECASE): + has_broad_grant = True + break + + if not has_broad_grant: + return False # Only kindred grants, exclude + + # Check if card has inherent protection keywords + has_inherent = False + if keywords: + keywords_lower = keywords.lower() + has_inherent = any(k in keywords_lower for k in PROTECTION_KEYWORDS) + + # Check for explicit grants with protection keywords + found_grant = False + + # Mass grant patterns (creatures you control have/gain) + 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: + match = re.search(pattern, text_lower, re.IGNORECASE) + if match: + # Check if protection keyword appears after the grant verb (within 70 chars) + 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 + + # Grant verb patterns (creature gains/gets hexproof) + if not found_grant: + for pattern in GRANT_VERB_PATTERNS: + if re.search(pattern, text_lower, re.IGNORECASE): + found_grant = True + break + + # If we have inherent protection and the ONLY text is about stats (no grant words), exclude + if has_inherent and not found_grant: + # Check if text only talks about other stats (power/toughness, +X/+X) + has_stat_only = bool(re.search(r'(get[s]?|gain[s]?)\s+[+\-][0-9X]+/[+\-][0-9X]+', text_lower)) + # Check if text mentions "other" without protection keywords + mentions_other_without_prot = 'other' in text_lower and not any(prot in text_lower for prot in PROTECTION_KEYWORDS if prot in text_lower[text_lower.find('other'):]) + + if has_stat_only or mentions_other_without_prot: + return False + + return found_grant + + +def categorize_protection_card(name: str, text: str, keywords: str, card_type: str, exclude_kindred: bool = False) -> str: + """ + Categorize a Protection-tagged card for audit purposes. + + Args: + name: Card name + text: Card text + keywords: Card keywords + card_type: Card type line + exclude_kindred: If True, kindred-specific grants are categorized as metadata, not Grant + + Returns: + 'Grant' - gives broad protection to others + 'Kindred' - gives kindred-specific protection (metadata tag) + 'Inherent' - has protection itself + 'ConditionalSelf' - only conditionally grants to itself + 'Opponent' - grants to opponent's permanents + 'Neither' - false positive + """ + keywords_lower = keywords.lower() if keywords else '' + + # Check for opponent grants first + if is_opponent_grant(text): + return 'Opponent' + + # Check for conditional self-grants (ONLY self, no other grants) + if is_conditional_self_grant(text): + return 'ConditionalSelf' + + # Check if it has conditional self-grant (may also have other grants) + has_cond_self = has_conditional_self_grant(text) + + # Check if it has inherent protection + has_inherent = any(k in keywords_lower for k in PROTECTION_KEYWORDS) + + # Check for kindred-specific grants + kindred_tags = get_kindred_protection_tags(text) + if kindred_tags and exclude_kindred: + # Check if there's ALSO a broad grant (excluding kindred) + grants_broad = is_granting_protection(text, keywords, exclude_kindred=True) + + if grants_broad and has_inherent: + # Has inherent + kindred + broad grants + return 'Mixed' + elif grants_broad: + # Has kindred + broad grants (but no inherent) + # This is just Grant with kindred metadata tags + return 'Grant' + elif has_inherent: + # Has inherent + kindred only (not broad) + # This is still just Kindred category (inherent is separate from granting) + return 'Kindred' + else: + # Only kindred grants, no inherent or broad + return 'Kindred' + + # Check if it grants protection broadly (not kindred-specific) + grants_protection = is_granting_protection(text, keywords, exclude_kindred=exclude_kindred) + + # Categorize based on what it does + if grants_protection and has_cond_self: + # Has conditional self-grant + grants to others = Mixed + return 'Mixed' + elif grants_protection and has_inherent: + return 'Mixed' # Has inherent + grants broadly + elif grants_protection: + return 'Grant' # Only grants broadly + elif has_inherent: + return 'Inherent' # Only has inherent + else: + return 'Neither' # False positive diff --git a/code/tagging/tag_constants.py b/code/tagging/tag_constants.py index 30d70dc..6e5f3c4 100644 --- a/code/tagging/tag_constants.py +++ b/code/tagging/tag_constants.py @@ -849,4 +849,89 @@ TOPDECK_EXCLUSION_PATTERNS: List[str] = [ 'from the top of their library', 'look at the top card of target player\'s library', 'reveal the top card of target player\'s library' +] + +# ============================================================================== +# Keyword Normalization (M1 - Tagging Refinement) +# ============================================================================== + +# Keyword normalization map: variant -> canonical +# Maps Commander-specific and variant keywords to their canonical forms +KEYWORD_NORMALIZATION_MAP: Dict[str, str] = { + # Commander variants + 'Commander ninjutsu': 'Ninjutsu', + 'Commander Ninjutsu': 'Ninjutsu', + + # Partner variants (already excluded but mapped for reference) + 'Partner with': 'Partner', + 'Choose a Background': 'Choose a Background', # Keep distinct + "Doctor's Companion": "Doctor's Companion", # Keep distinct + + # Case normalization for common keywords (most are already correct) + 'flying': 'Flying', + 'trample': 'Trample', + 'vigilance': 'Vigilance', + 'haste': 'Haste', + 'deathtouch': 'Deathtouch', + 'lifelink': 'Lifelink', + 'menace': 'Menace', + 'reach': 'Reach', +} + +# Keywords that should never appear in theme tags +# Already excluded during keyword tagging, but documented here +KEYWORD_EXCLUSION_SET: set[str] = { + 'partner', # Already excluded in tag_for_keywords +} + +# Keyword allowlist - keywords that should survive singleton pruning +# Seeded from top keywords and theme whitelist +KEYWORD_ALLOWLIST: set[str] = { + # Evergreen keywords (top 50 from baseline) + 'Flying', 'Enchant', 'Trample', 'Vigilance', 'Haste', 'Equip', 'Flash', + 'Mill', 'Scry', 'Transform', 'Cycling', 'First strike', 'Reach', 'Menace', + 'Lifelink', 'Treasure', 'Defender', 'Deathtouch', 'Kicker', 'Flashback', + 'Protection', 'Surveil', 'Landfall', 'Crew', 'Ward', 'Morph', 'Devoid', + 'Investigate', 'Fight', 'Food', 'Partner', 'Double strike', 'Indestructible', + 'Threshold', 'Proliferate', 'Convoke', 'Hexproof', 'Cumulative upkeep', + 'Goad', 'Delirium', 'Prowess', 'Suspend', 'Affinity', 'Madness', 'Manifest', + 'Amass', 'Domain', 'Unearth', 'Explore', 'Changeling', + + # Additional important mechanics + 'Myriad', 'Cascade', 'Storm', 'Dredge', 'Delve', 'Escape', 'Mutate', + 'Ninjutsu', 'Overload', 'Rebound', 'Retrace', 'Bloodrush', 'Cipher', + 'Extort', 'Evolve', 'Undying', 'Persist', 'Wither', 'Infect', 'Annihilator', + 'Exalted', 'Phasing', 'Shadow', 'Horsemanship', 'Banding', 'Rampage', + 'Shroud', 'Split second', 'Totem armor', 'Living weapon', 'Undaunted', + 'Improvise', 'Surge', 'Emerge', 'Escalate', 'Meld', 'Partner', 'Afflict', + 'Aftermath', 'Embalm', 'Eternalize', 'Exert', 'Fabricate', 'Improvise', + 'Assist', 'Jump-start', 'Mentor', 'Riot', 'Spectacle', 'Addendum', + 'Afterlife', 'Adapt', 'Enrage', 'Ascend', 'Learn', 'Boast', 'Foretell', + 'Squad', 'Encore', 'Daybound', 'Nightbound', 'Disturb', 'Cleave', 'Training', + 'Reconfigure', 'Blitz', 'Casualty', 'Connive', 'Hideaway', 'Prototype', + 'Read ahead', 'Living metal', 'More than meets the eye', 'Ravenous', + 'Squad', 'Toxic', 'For Mirrodin!', 'Backup', 'Bargain', 'Craft', 'Freerunning', + 'Plot', 'Spree', 'Offspring', 'Bestow', 'Monstrosity', 'Tribute', + + # Partner mechanics (distinct types) + 'Choose a Background', "Doctor's Companion", + + # Token types (frequently used) + 'Blood', 'Clue', 'Food', 'Gold', 'Treasure', 'Powerstone', + + # Common ability words + 'Landfall', 'Raid', 'Revolt', 'Threshold', 'Metalcraft', 'Morbid', + 'Bloodthirst', 'Battalion', 'Channel', 'Grandeur', 'Kinship', 'Sweep', + 'Radiance', 'Join forces', 'Fateful hour', 'Inspired', 'Heroic', + 'Constellation', 'Strive', 'Prowess', 'Ferocious', 'Formidable', 'Renown', + '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_PREFIXES: List[str] = [ + 'Applied:', + 'Bracket:', + 'Diagnostic:', + 'Internal:', ] \ No newline at end of file diff --git a/code/tagging/tag_utils.py b/code/tagging/tag_utils.py index 156f0f5..e731f07 100644 --- a/code/tagging/tag_utils.py +++ b/code/tagging/tag_utils.py @@ -509,4 +509,77 @@ def create_mass_damage_mask(df: pd.DataFrame) -> pd.Series[bool]: damage_mask = create_text_mask(df, number_patterns) target_mask = create_text_mask(df, target_patterns) - return damage_mask & target_mask \ No newline at end of file + return damage_mask & target_mask + + +# ============================================================================== +# Keyword Normalization (M1 - Tagging Refinement) +# ============================================================================== + +def normalize_keywords( + raw: Union[List[str], Set[str], Tuple[str, ...]], + allowlist: Set[str], + frequency_map: dict[str, int] +) -> list[str]: + """Normalize keyword strings for theme tagging. + + Applies normalization rules: + 1. Case normalization (via normalization map) + 2. Canonical mapping (e.g., "Commander Ninjutsu" -> "Ninjutsu") + 3. Singleton pruning (unless allowlisted) + 4. Deduplication + 5. Exclusion of blacklisted keywords + + Args: + raw: Iterable of raw keyword strings + allowlist: Set of keywords that should survive singleton pruning + frequency_map: Dict mapping keywords to their occurrence count + + Returns: + Deduplicated list of normalized keywords + + Raises: + ValueError: If raw is not iterable + + Examples: + >>> normalize_keywords( + ... ['Commander Ninjutsu', 'Flying', 'Allons-y!'], + ... {'Flying', 'Ninjutsu'}, + ... {'Commander Ninjutsu': 2, 'Flying': 100, 'Allons-y!': 1} + ... ) + ['Ninjutsu', 'Flying'] # 'Allons-y!' pruned as singleton + """ + if not hasattr(raw, '__iter__') or isinstance(raw, (str, bytes)): + raise ValueError(f"raw must be iterable, got {type(raw)}") + + normalized_keywords: set[str] = set() + + for keyword in raw: + # Skip non-string entries + if not isinstance(keyword, str): + continue + + # Skip empty strings + keyword = keyword.strip() + if not keyword: + continue + + # Skip excluded keywords + if keyword.lower() in tag_constants.KEYWORD_EXCLUSION_SET: + continue + + # Apply normalization map + normalized = tag_constants.KEYWORD_NORMALIZATION_MAP.get(keyword, keyword) + + # Check if singleton (unless allowlisted) + frequency = frequency_map.get(keyword, 0) + is_singleton = frequency == 1 + is_allowlisted = normalized in allowlist or keyword in allowlist + + # Prune singletons that aren't allowlisted + if is_singleton and not is_allowlisted: + continue + + normalized_keywords.add(normalized) + + return sorted(list(normalized_keywords)) \ No newline at end of file diff --git a/code/tagging/tagger.py b/code/tagging/tagger.py index 6d3c21e..b2b3f0b 100644 --- a/code/tagging/tagger.py +++ b/code/tagging/tagger.py @@ -580,6 +580,11 @@ def add_creatures_to_tags(df: pd.DataFrame, color: str) -> None: ## Add keywords to theme tags def tag_for_keywords(df: pd.DataFrame, color: str) -> None: """Tag cards based on their keywords using vectorized operations. + + When TAG_NORMALIZE_KEYWORDS is enabled, applies normalization: + - Canonical mapping (e.g., "Commander Ninjutsu" -> "Ninjutsu") + - Singleton pruning (unless allowlisted) + - Case normalization Args: df: DataFrame containing card data @@ -589,6 +594,20 @@ def tag_for_keywords(df: pd.DataFrame, color: str) -> None: start_time = pd.Timestamp.now() try: + from settings import TAG_NORMALIZE_KEYWORDS + + # Load frequency map if normalization is enabled + frequency_map: dict[str, int] = {} + if TAG_NORMALIZE_KEYWORDS: + freq_map_path = Path(__file__).parent / 'keyword_frequency_map.json' + if freq_map_path.exists(): + with open(freq_map_path, 'r', encoding='utf-8') as f: + frequency_map = json.load(f) + logger.info('Loaded keyword frequency map with %d entries', len(frequency_map)) + else: + logger.warning('Keyword frequency map not found, normalization disabled for this run') + TAG_NORMALIZE_KEYWORDS = False + # Create mask for valid keywords has_keywords = pd.notna(df['keywords']) @@ -608,17 +627,29 @@ def tag_for_keywords(df: pd.DataFrame, color: str) -> None: else: keywords_iterable = [] - filtered_keywords = [ - kw for kw in keywords_iterable - if kw and kw.lower() not in exclusion_keywords - ] - - return sorted(list(set(base_tags + filtered_keywords))) + # Apply normalization if enabled + if TAG_NORMALIZE_KEYWORDS and frequency_map: + normalized_keywords = tag_utils.normalize_keywords( + keywords_iterable, + tag_constants.KEYWORD_ALLOWLIST, + frequency_map + ) + return sorted(list(set(base_tags + normalized_keywords))) + else: + # Legacy behavior: simple exclusion filter + filtered_keywords = [ + kw for kw in keywords_iterable + if kw and kw.lower() not in exclusion_keywords + ] + return sorted(list(set(base_tags + filtered_keywords))) df.loc[has_keywords, 'themeTags'] = keywords_df.apply(_merge_keywords, axis=1) duration = (pd.Timestamp.now() - start_time).total_seconds() logger.info('Tagged %d cards with keywords in %.2f seconds', has_keywords.sum(), duration) + + if TAG_NORMALIZE_KEYWORDS: + logger.info('Keyword normalization enabled for %s', color) except Exception as e: logger.error('Error tagging keywords: %s', str(e)) @@ -7000,6 +7031,9 @@ def tag_for_protection(df: pd.DataFrame, color: str) -> None: - Ward - Phase out + With TAG_PROTECTION_GRANTS=1, only tags cards that grant protection to other + permanents, filtering out cards with inherent protection. + The function uses helper functions to identify different types of protection and applies tags consistently using vectorized operations. @@ -7025,13 +7059,47 @@ def tag_for_protection(df: pd.DataFrame, color: str) -> None: required_cols = {'text', 'themeTags', 'keywords'} tag_utils.validate_dataframe_columns(df, required_cols) - # Create masks for different protection patterns - text_mask = create_protection_text_mask(df) - keyword_mask = create_protection_keyword_mask(df) - exclusion_mask = create_protection_exclusion_mask(df) + # Check if grant detection is enabled (M2 feature flag) + use_grant_detection = os.getenv('TAG_PROTECTION_GRANTS', '1').lower() in ('1', 'true', 'yes') - # Combine masks - final_mask = (text_mask | keyword_mask) & ~exclusion_mask + if use_grant_detection: + # M2: Use grant detection to filter out inherent-only protection + from code.tagging.protection_grant_detection import is_granting_protection, get_kindred_protection_tags + + # Create a grant detection mask + grant_mask = df.apply( + lambda row: is_granting_protection( + str(row.get('text', '')), + str(row.get('keywords', '')) + ), + axis=1 + ) + + final_mask = grant_mask + logger.info(f'Using M2 grant detection (TAG_PROTECTION_GRANTS=1)') + + # Apply kindred metadata tags for creature-type-specific grants + 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)) + kindred_count += 1 + + if kindred_count > 0: + logger.info(f'Applied kindred metadata tags to {kindred_count} cards') + else: + # Legacy: Use original text/keyword patterns + text_mask = create_protection_text_mask(df) + keyword_mask = create_protection_keyword_mask(df) + exclusion_mask = create_protection_exclusion_mask(df) + final_mask = (text_mask | keyword_mask) & ~exclusion_mask # Apply tags via rules engine tag_utils.apply_rules(df, rules=[ diff --git a/code/tests/test_keyword_normalization.py b/code/tests/test_keyword_normalization.py new file mode 100644 index 0000000..002adc8 --- /dev/null +++ b/code/tests/test_keyword_normalization.py @@ -0,0 +1,182 @@ +"""Tests for keyword normalization (M1 - Tagging Refinement).""" +from __future__ import annotations + +import pytest + +from code.tagging import tag_utils, tag_constants + + +class TestKeywordNormalization: + """Test suite for normalize_keywords function.""" + + def test_canonical_mappings(self): + """Test that variant keywords map to canonical forms.""" + raw = ['Commander Ninjutsu', 'Flying', 'Trample'] + allowlist = tag_constants.KEYWORD_ALLOWLIST + frequency_map = { + 'Commander Ninjutsu': 2, + 'Flying': 100, + 'Trample': 50 + } + + result = tag_utils.normalize_keywords(raw, allowlist, frequency_map) + + assert 'Ninjutsu' in result + assert 'Flying' in result + assert 'Trample' in result + assert 'Commander Ninjutsu' not in result + + def test_singleton_pruning(self): + """Test that singleton keywords are pruned unless allowlisted.""" + raw = ['Allons-y!', 'Flying', 'Take 59 Flights of Stairs'] + allowlist = {'Flying'} # Only Flying is allowlisted + frequency_map = { + 'Allons-y!': 1, + 'Flying': 100, + 'Take 59 Flights of Stairs': 1 + } + + result = tag_utils.normalize_keywords(raw, allowlist, frequency_map) + + assert 'Flying' in result + assert 'Allons-y!' not in result + assert 'Take 59 Flights of Stairs' not in result + + def test_case_normalization(self): + """Test that keywords are normalized to proper case.""" + raw = ['flying', 'TRAMPLE', 'vigilance'] + allowlist = {'Flying', 'Trample', 'Vigilance'} + frequency_map = { + 'flying': 100, + 'TRAMPLE': 50, + 'vigilance': 75 + } + + result = tag_utils.normalize_keywords(raw, allowlist, frequency_map) + + # Case normalization happens via the map + # If not in map, original case is preserved + assert len(result) == 3 + + def test_partner_exclusion(self): + """Test that partner keywords remain excluded.""" + raw = ['Partner', 'Flying', 'Trample'] + allowlist = {'Flying', 'Trample'} + frequency_map = { + 'Partner': 50, + 'Flying': 100, + 'Trample': 50 + } + + result = tag_utils.normalize_keywords(raw, allowlist, frequency_map) + + assert 'Flying' in result + assert 'Trample' in result + assert 'Partner' not in result # Excluded + assert 'partner' not in result + + def test_empty_input(self): + """Test that empty input returns empty list.""" + result = tag_utils.normalize_keywords([], set(), {}) + assert result == [] + + def test_whitespace_handling(self): + """Test that whitespace is properly stripped.""" + raw = [' Flying ', 'Trample ', ' Vigilance'] + allowlist = {'Flying', 'Trample', 'Vigilance'} + frequency_map = { + 'Flying': 100, + 'Trample': 50, + 'Vigilance': 75 + } + + result = tag_utils.normalize_keywords(raw, allowlist, frequency_map) + + assert 'Flying' in result + assert 'Trample' in result + assert 'Vigilance' in result + + def test_deduplication(self): + """Test that duplicate keywords are deduplicated.""" + raw = ['Flying', 'Flying', 'Trample', 'Flying'] + allowlist = {'Flying', 'Trample'} + frequency_map = { + 'Flying': 100, + 'Trample': 50 + } + + result = tag_utils.normalize_keywords(raw, allowlist, frequency_map) + + assert result.count('Flying') == 1 + assert result.count('Trample') == 1 + + def test_non_string_entries_skipped(self): + """Test that non-string entries are safely skipped.""" + raw = ['Flying', None, 123, 'Trample', ''] + allowlist = {'Flying', 'Trample'} + frequency_map = { + 'Flying': 100, + 'Trample': 50 + } + + result = tag_utils.normalize_keywords(raw, allowlist, frequency_map) + + assert 'Flying' in result + assert 'Trample' in result + assert len(result) == 2 + + def test_invalid_input_raises_error(self): + """Test that non-iterable input raises ValueError.""" + with pytest.raises(ValueError, match="raw must be iterable"): + tag_utils.normalize_keywords("not-a-list", set(), {}) + + def test_allowlist_preserves_singletons(self): + """Test that allowlisted keywords survive even if they're singletons.""" + raw = ['Myriad', 'Flying', 'Cascade'] + allowlist = {'Flying', 'Myriad', 'Cascade'} # All allowlisted + frequency_map = { + 'Myriad': 1, # Singleton + 'Flying': 100, + 'Cascade': 1 # Singleton + } + + result = tag_utils.normalize_keywords(raw, allowlist, frequency_map) + + assert 'Myriad' in result # Preserved despite being singleton + assert 'Flying' in result + assert 'Cascade' in result # Preserved despite being singleton + + +class TestKeywordIntegration: + """Integration tests for keyword normalization in tagging flow.""" + + def test_normalization_preserves_evergreen_keywords(self): + """Test that common evergreen keywords are always preserved.""" + evergreen = ['Flying', 'Trample', 'Vigilance', 'Haste', 'Deathtouch', 'Lifelink'] + allowlist = tag_constants.KEYWORD_ALLOWLIST + frequency_map = {kw: 100 for kw in evergreen} # All common + + result = tag_utils.normalize_keywords(evergreen, allowlist, frequency_map) + + for kw in evergreen: + assert kw in result + + def test_crossover_keywords_pruned(self): + """Test that crossover-specific singletons are pruned.""" + crossover_singletons = [ + 'Gae Bolg', # Final Fantasy + 'Psychic Defense', # Warhammer 40K + 'Allons-y!', # Doctor Who + 'Flying' # Evergreen (control) + ] + allowlist = {'Flying'} # Only Flying allowed + frequency_map = { + 'Gae Bolg': 1, + 'Psychic Defense': 1, + 'Allons-y!': 1, + 'Flying': 100 + } + + result = tag_utils.normalize_keywords(crossover_singletons, allowlist, frequency_map) + + assert result == ['Flying'] # Only evergreen survived diff --git a/code/tests/test_protection_grant_detection.py b/code/tests/test_protection_grant_detection.py new file mode 100644 index 0000000..435377b --- /dev/null +++ b/code/tests/test_protection_grant_detection.py @@ -0,0 +1,169 @@ +""" +Tests for protection grant detection (M2). + +Tests the ability to distinguish between cards that grant protection +and cards that have inherent protection. +""" + +import pytest +from code.tagging.protection_grant_detection import ( + is_granting_protection, + categorize_protection_card +) + + +class TestGrantDetection: + """Test grant verb detection.""" + + def test_gains_hexproof(self): + """Cards with 'gains hexproof' should be detected as granting.""" + text = "Target creature gains hexproof until end of turn." + assert is_granting_protection(text, "") + + def test_gives_indestructible(self): + """Cards with 'gives indestructible' should be detected as granting.""" + text = "This creature gives target creature indestructible." + assert is_granting_protection(text, "") + + def test_creatures_you_control_have(self): + """Mass grant pattern should be detected.""" + text = "Creatures you control have hexproof." + assert is_granting_protection(text, "") + + def test_equipped_creature_gets(self): + """Equipment grant pattern should be detected.""" + text = "Equipped creature gets +2/+2 and has indestructible." + assert is_granting_protection(text, "") + + +class TestInherentDetection: + """Test inherent protection detection.""" + + def test_creature_with_hexproof_keyword(self): + """Creature with hexproof keyword should not be detected as granting.""" + text = "Hexproof (This creature can't be the target of spells or abilities.)" + keywords = "Hexproof" + assert not is_granting_protection(text, keywords) + + def test_indestructible_artifact(self): + """Artifact with indestructible keyword should not be detected as granting.""" + text = "Indestructible" + keywords = "Indestructible" + assert not is_granting_protection(text, keywords) + + def test_ward_creature(self): + """Creature with Ward should not be detected as granting (unless it grants to others).""" + text = "Ward {2}" + keywords = "Ward" + assert not is_granting_protection(text, keywords) + + +class TestMixedCases: + """Test cards that both grant and have protection.""" + + def test_creature_with_self_grant(self): + """Creature that grants itself protection should be detected.""" + text = "This creature gains indestructible until end of turn." + keywords = "" + assert is_granting_protection(text, keywords) + + def test_equipment_with_inherent_and_grant(self): + """Equipment with indestructible that grants protection.""" + text = "Indestructible. Equipped creature has hexproof." + keywords = "Indestructible" + # Should be detected as granting because of "has hexproof" + assert is_granting_protection(text, keywords) + + +class TestExclusions: + """Test exclusion patterns.""" + + def test_cant_have_hexproof(self): + """Cards that prevent protection should not be tagged.""" + text = "Creatures your opponents control can't have hexproof." + assert not is_granting_protection(text, "") + + def test_loses_indestructible(self): + """Cards that remove protection should not be tagged.""" + text = "Target creature loses indestructible until end of turn." + assert not is_granting_protection(text, "") + + +class TestEdgeCases: + """Test edge cases and special patterns.""" + + def test_protection_from_color(self): + """Protection from [quality] in keywords without grant text.""" + text = "Protection from red" + keywords = "Protection from red" + assert not is_granting_protection(text, keywords) + + def test_empty_text(self): + """Empty text should return False.""" + assert not is_granting_protection("", "") + + def test_none_text(self): + """None text should return False.""" + assert not is_granting_protection(None, "") + + +class TestCategorization: + """Test full card categorization.""" + + def test_shell_shield_is_grant(self): + """Shell Shield grants hexproof - should be Grant.""" + text = "Target creature gets +0/+3 and gains hexproof until end of turn." + cat = categorize_protection_card("Shell Shield", text, "", "Instant") + assert cat == "Grant" + + def test_geist_of_saint_traft_is_mixed(self): + """Geist has hexproof and creates tokens - Mixed.""" + text = "Hexproof. Whenever this attacks, create a token." + keywords = "Hexproof" + cat = categorize_protection_card("Geist", text, keywords, "Creature") + # Has hexproof keyword, so inherent + assert cat in ("Inherent", "Mixed") + + def test_darksteel_brute_is_inherent(self): + """Darksteel Brute has indestructible - should be Inherent.""" + text = "Indestructible" + keywords = "Indestructible" + cat = categorize_protection_card("Darksteel Brute", text, keywords, "Artifact") + assert cat == "Inherent" + + def test_scion_of_oona_is_grant(self): + """Scion of Oona grants shroud to other faeries - should be Grant.""" + text = "Other Faeries you control have shroud." + keywords = "Flying, Flash" + cat = categorize_protection_card("Scion of Oona", text, keywords, "Creature") + assert cat == "Grant" + + +class TestRealWorldCards: + """Test against actual card samples from baseline audit.""" + + def test_bulwark_ox(self): + """Bulwark Ox - grants hexproof and indestructible.""" + text = "Sacrifice: Creatures you control with counters gain hexproof and indestructible" + assert is_granting_protection(text, "") + + def test_bloodsworn_squire(self): + """Bloodsworn Squire - grants itself indestructible.""" + text = "This creature gains indestructible until end of turn" + assert is_granting_protection(text, "") + + def test_kaldra_compleat(self): + """Kaldra Compleat - equipment with indestructible that grants.""" + text = "Indestructible. Equipped creature gets +5/+5 and has indestructible" + keywords = "Indestructible" + assert is_granting_protection(text, keywords) + + def test_ward_sliver(self): + """Ward Sliver - grants protection to all slivers.""" + text = "All Slivers have protection from the chosen color" + assert is_granting_protection(text, "") + + def test_rebbec(self): + """Rebbec - grants protection to artifacts.""" + text = "Artifacts you control have protection from each mana value" + assert is_granting_protection(text, "") diff --git a/code/web/routes/build.py b/code/web/routes/build.py index e058ed6..676ae71 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -170,7 +170,7 @@ def _step5_summary_placeholder_html(token: int, *, message: str | None = None) - return ( f'
' + 'hx-trigger="step5:refresh from:body" hx-swap="outerHTML">' f'
{_esc(text)}
' '
' ) diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index 422a355..9b9f8b4 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -1181,6 +1181,9 @@ def _ensure_setup_ready(out, force: bool = False) -> None: # Only flip phase if previous run finished if st.get('phase') in {'themes','themes-fast'}: st['phase'] = 'done' + # Also ensure percent is 100 when done + if st.get('finished_at'): + st['percent'] = 100 with open(status_path, 'w', encoding='utf-8') as _wf: json.dump(st, _wf) except Exception: @@ -1463,16 +1466,17 @@ def _ensure_setup_ready(out, force: bool = False) -> None: except Exception: pass - # Unconditional fallback: if (for any reason) no theme export ran above, perform a fast-path export now. - # This guarantees that clicking Run Setup/Tagging always leaves themes current even when tagging wasn't needed. + # Conditional fallback: only run theme export if refresh_needed was True but somehow no export performed. + # This avoids repeated exports when setup is already complete and _ensure_setup_ready is called again. try: - if not theme_export_performed: + if not theme_export_performed and refresh_needed: _refresh_theme_catalog(out, force=False, fast_path=True) except Exception: pass else: # If export just ran (either earlier or via fallback), ensure enrichment ran (safety double-call guard inside helper) try: - _run_theme_metadata_enrichment(out) + if theme_export_performed or refresh_needed: + _run_theme_metadata_enrichment(out) except Exception: pass diff --git a/code/web/templates/base.html b/code/web/templates/base.html index 576d9af..050d57c 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -309,7 +309,8 @@ .catch(function(){ /* noop */ }); } catch(e) {} } - setInterval(pollStatus, 3000); + // Poll every 10 seconds instead of 3 to reduce server load (only for header indicator) + setInterval(pollStatus, 10000); pollStatus(); // Health indicator poller diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html index 9757ff0..b1d4b88 100644 --- a/code/web/templates/build/_step5.html +++ b/code/web/templates/build/_step5.html @@ -462,11 +462,12 @@ {% if allow_must_haves %} - {% include "partials/include_exclude_summary.html" with oob=False %} + {% set oob = False %} + {% include "partials/include_exclude_summary.html" %} {% endif %}
{% if summary_ready %}Loading deck summary…{% else %}Deck summary will appear after the build completes.{% endif %} diff --git a/code/web/templates/setup/running.html b/code/web/templates/setup/running.html index eb94c8a..e272a3a 100644 --- a/code/web/templates/setup/running.html +++ b/code/web/templates/setup/running.html @@ -127,7 +127,8 @@ .then(update) .catch(function(){}); } - setInterval(poll, 3000); + // Poll every 5 seconds instead of 3 to reduce server load + setInterval(poll, 5000); poll(); })(); diff --git a/config/themes/theme_list.json b/config/themes/theme_list.json index 1047f02..ba5fa1d 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)" + "Krenko, Tin Street Kingpin - Synergy (Counters Matter)", + "Yawgmoth, Thran Physician - Synergy (Counters Matter)" ], "example_cards": [ "Wall of Roots", @@ -118,9 +118,6 @@ "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." @@ -155,7 +152,7 @@ "Silverflame Ritual" ], "synergy_commanders": [ - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yawgmoth, Thran Physician - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -191,8 +188,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", @@ -211,8 +208,8 @@ "secondary_color": "Blue", "example_commanders": [ "Syr Konrad, the Grim - Synergy (Interaction)", - "Toski, Bearer of Secrets - Synergy (Interaction)", "Purphoros, God of the Forge - Synergy (Interaction)", + "Boromir, Warden of the Tower - Synergy (Interaction)", "Lotho, Corrupt Shirriff - Synergy (Spells Matter)", "Birgi, God of Storytelling // Harnfel, Horn of Bounty - Synergy (Spells Matter)" ], @@ -490,7 +487,7 @@ "Cosima, God of the Voyage // The Omenkeel" ], "synergy_commanders": [ - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Rishkar, Peema Renegade - Synergy (Counters Matter)" ], "popularity_bucket": "Niche", "editorial_quality": "draft", @@ -560,11 +557,11 @@ "id": "alien-kindred", "theme": "Alien Kindred", "synergies": [ + "Clones", "Horror Kindred", "Exile Matters", "Trample", - "Protection", - "Counters Matter" + "Soldier Kindred" ], "primary_color": "Blue", "secondary_color": "Green", @@ -586,12 +583,12 @@ "Time Beetle" ], "synergy_commanders": [ - "Mondrak, Glory Dominus - Synergy (Horror Kindred)", + "Mondrak, Glory Dominus - Synergy (Clones)", + "Kiki-Jiki, Mirror Breaker - Synergy (Clones)", + "Sakashima of a Thousand Faces - Synergy (Clones)", "Solphim, Mayhem Dominus - Synergy (Horror Kindred)", "Zopandrel, Hunger Dominus - Synergy (Horror Kindred)", - "Etali, Primal Storm - Synergy (Exile Matters)", - "Ragavan, Nimble Pilferer - Synergy (Exile Matters)", - "Ghalta, Primal Hunger - Synergy (Trample)" + "Etali, Primal Storm - Synergy (Exile Matters)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -725,8 +722,8 @@ "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)", - "Yahenni, Undying Partisan - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)", + "Heliod, Sun-Crowned - Synergy (Counters Matter)" ], "example_cards": [ "Kilnmouth Dragon", @@ -848,7 +845,7 @@ "Artifacts Matter", "Big Mana", "Toughness Matters", - "Aggro" + "Interaction" ], "primary_color": "Green", "secondary_color": "Red", @@ -2435,7 +2432,7 @@ "Ziatora's Envoy", "Mezzio Mugger", "Sabin, Master Monk", - "Night Clubber" + "Riveteers Requisitioner" ], "synergy_commanders": [ "Kutzil, Malamet Exemplar - Synergy (Warrior Kindred)", @@ -2459,21 +2456,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" + "Anje, Maid of Dishonor", + "Shilgengar, Sire of Famine" ], "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" + "Font of Agonies", + "Exsanguinator Cavalry" ], "synergy_commanders": [ "Indoraptor, the Perfect Hybrid - Synergy (Bloodthirst)", @@ -2494,9 +2491,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)" ], @@ -2531,9 +2528,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": [ @@ -2626,8 +2623,7 @@ "synergy_commanders": [ "Hokori, Dust Drinker - Synergy (Bracket:MassLandDenial)", "Myojin of Infinite Rage - Synergy (Bracket:MassLandDenial)", - "Elas il-Kor, Sadistic Pilgrim - Synergy (Pingers)", - "Toski, Bearer of Secrets - Synergy (Interaction)" + "Elas il-Kor, Sadistic Pilgrim - Synergy (Pingers)" ], "popularity_bucket": "Very Common", "editorial_quality": "draft", @@ -2705,7 +2701,7 @@ "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", "Samut, Voice of Dissent - Synergy (Combat Tricks)", "Naru Meha, Master Wizard - Synergy (Combat Tricks)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -2841,8 +2837,8 @@ "Static Orb" ], "synergy_commanders": [ - "Toski, Bearer of Secrets - Synergy (Interaction)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Interaction)", + "Boromir, Warden of the Tower - Synergy (Interaction)", + "Avacyn, Angel of Hope - Synergy (Interaction)", "Lotho, Corrupt Shirriff - Synergy (Spells Matter)" ], "popularity_bucket": "Niche", @@ -3197,7 +3193,6 @@ "Seasoned Dungeoneer" ], "synergy_commanders": [ - "Kellan, Daring Traveler // Journey On - Synergy (Map Token)", "Selvala, Heart of the Wilds - Synergy (Scout Kindred)" ], "popularity_bucket": "Niche", @@ -3354,8 +3349,8 @@ "Kutzil, Malamet Exemplar", "Felidar Retreat", "Displacer Kitten", - "Temur Sabertooth", "Enduring Curiosity", + "Temur Sabertooth", "Lion Sash", "Ocelot Pride", "Felidar Guardian" @@ -3580,7 +3575,7 @@ "synergy_commanders": [ "Codsworth, Handy Helper - Synergy (Mana Rock)", "Karn, Legacy Reforged - Synergy (Mana Rock)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Rishkar, Peema Renegade - Synergy (Counters Matter)" ], "popularity_bucket": "Niche", "editorial_quality": "draft", @@ -4478,8 +4473,8 @@ "id": "convoke", "theme": "Convoke", "synergies": [ - "Knight Kindred", "Big Mana", + "Knight Kindred", "Toolbox", "Combat Tricks", "Removal" @@ -4491,7 +4486,7 @@ "The Wandering Rescuer", "Hogaak, Arisen Necropolis", "Kasla, the Broken Halo", - "Syr Konrad, the Grim - Synergy (Knight Kindred)" + "Syr Konrad, the Grim - Synergy (Big Mana)" ], "example_cards": [ "Chord of Calling", @@ -4504,10 +4499,10 @@ "March of the Multitudes" ], "synergy_commanders": [ - "Adeline, Resplendent Cathar - Synergy (Knight Kindred)", - "Danitha Capashen, Paragon - Synergy (Knight Kindred)", "Etali, Primal Storm - Synergy (Big Mana)", "Tatyova, Benthic Druid - Synergy (Big Mana)", + "Adeline, Resplendent Cathar - Synergy (Knight Kindred)", + "Danitha Capashen, Paragon - Synergy (Knight Kindred)", "Junji, the Midnight Sky - Synergy (Toolbox)" ], "popularity_bucket": "Niche", @@ -4546,9 +4541,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": [ @@ -4687,11 +4682,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" + "Yahenni, Undying Partisan", + "Heliod, Sun-Crowned" ], "example_cards": [ "The One Ring", @@ -4993,9 +4988,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)" + "Krenko, Tin Street Kingpin - Synergy (Counters Matter)", + "Yawgmoth, Thran Physician - Synergy (Counters Matter)" ], "example_cards": [ "The Pride of Hull Clade", @@ -5008,8 +5003,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", @@ -5046,8 +5041,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", @@ -5590,8 +5585,8 @@ "Azusa, Lost but Seeking - Synergy (Lands Matter)", "Tatyova, Benthic Druid - Synergy (Lands Matter)", "Sheoldred, Whispering One - Synergy (Lands Matter)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)", - "Rishkar, Peema Renegade - Synergy (Counters Matter)" + "Rishkar, Peema Renegade - Synergy (Counters Matter)", + "Krenko, Tin Street Kingpin - Synergy (Counters Matter)" ], "example_cards": [ "Sandstone Needle", @@ -5745,7 +5740,7 @@ "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)" ], "example_cards": [ "Scourge of the Throne", @@ -5758,7 +5753,7 @@ "Marchesa's Emissary" ], "synergy_commanders": [ - "Yahenni, Undying Partisan - Synergy (Counters Matter)", + "Heliod, Sun-Crowned - Synergy (Counters Matter)", "Sram, Senior Edificer - Synergy (Voltron)" ], "popularity_bucket": "Rare", @@ -5867,7 +5862,7 @@ "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)" ], "example_cards": [ "Mycoloth", @@ -5880,7 +5875,7 @@ "Voracious Dragon" ], "synergy_commanders": [ - "Yahenni, Undying Partisan - Synergy (Counters Matter)", + "Heliod, Sun-Crowned - Synergy (Counters Matter)", "Sram, Senior Edificer - Synergy (Voltron)" ], "popularity_bucket": "Rare", @@ -6086,10 +6081,8 @@ "id": "divinity-counters", "theme": "Divinity Counters", "synergies": [ - "Protection", "Spirit Kindred", "Counters Matter", - "Interaction", "Big Mana" ], "primary_color": "White", @@ -6111,12 +6104,12 @@ "Myojin of Infinite Rage" ], "synergy_commanders": [ - "Toski, Bearer of Secrets - Synergy (Protection)", - "Purphoros, God of the Forge - Synergy (Protection)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Protection)", "Kodama of the West Tree - Synergy (Spirit Kindred)", "Kodama of the East Tree - Synergy (Spirit Kindred)", - "Rishkar, Peema Renegade - Synergy (Counters Matter)" + "Junji, the Midnight Sky - Synergy (Spirit Kindred)", + "Rishkar, Peema Renegade - Synergy (Counters Matter)", + "Krenko, Tin Street Kingpin - Synergy (Counters Matter)", + "Syr Konrad, the Grim - Synergy (Big Mana)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -6783,7 +6776,6 @@ "synergies": [ "Flying", "Burn", - "Interaction", "Little Fellas" ], "primary_color": "Red", @@ -6793,7 +6785,7 @@ "Plargg and Nassari", "Yusri, Fortune's Flame", "Najal, the Storm Runner", - "Uvilda, Dean of Perfection // Nassari, Dean of Expression" + "Niv-Mizzet, Parun - Synergy (Flying)" ], "example_cards": [ "Veyran, Voice of Duality", @@ -6802,16 +6794,15 @@ "Najal, the Storm Runner", "Frenetic Efreet", "Efreet Flamepainter", - "Uvilda, Dean of Perfection // Nassari, Dean of Expression", - "Emissary of Grudges" + "Emissary of Grudges", + "Capricious Efreet" ], "synergy_commanders": [ - "Niv-Mizzet, Parun - Synergy (Flying)", "Avacyn, Angel of Hope - Synergy (Flying)", "Old Gnawbone - Synergy (Flying)", "Syr Konrad, the Grim - Synergy (Burn)", "Braids, Arisen Nightmare - Synergy (Burn)", - "Toski, Bearer of Secrets - Synergy (Interaction)" + "Ragavan, Nimble Pilferer - Synergy (Little Fellas)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -7568,7 +7559,7 @@ "Combat Tricks", "Spells Matter", "Spellslinger", - "Removal" + "Interaction" ], "primary_color": "Green", "secondary_color": "Black", @@ -7757,8 +7748,8 @@ "secondary_color": "White", "example_commanders": [ "Syr Konrad, the Grim - Synergy (Interaction)", - "Toski, Bearer of Secrets - Synergy (Interaction)", "Purphoros, God of the Forge - Synergy (Interaction)", + "Boromir, Warden of the Tower - Synergy (Interaction)", "Lotho, Corrupt Shirriff - Synergy (Spells Matter)", "Birgi, God of Storytelling // Harnfel, Horn of Bounty - Synergy (Spells Matter)" ], @@ -7904,7 +7895,7 @@ "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)" ], "example_cards": [ "Gyre Sage", @@ -7917,7 +7908,7 @@ "Experiment One" ], "synergy_commanders": [ - "Yahenni, Undying Partisan - Synergy (Counters Matter)", + "Heliod, Sun-Crowned - Synergy (Counters Matter)", "Sram, Senior Edificer - Synergy (Voltron)" ], "popularity_bucket": "Rare", @@ -8029,7 +8020,7 @@ "The Indomitable - Synergy (Vehicles)", "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yawgmoth, Thran Physician - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -8168,7 +8159,6 @@ "Nicanzil, Current Conductor" ], "synergy_commanders": [ - "Kellan, Daring Traveler // Journey On - Synergy (Map Token)", "Selvala, Heart of the Wilds - Synergy (Scout Kindred)" ], "popularity_bucket": "Niche", @@ -8290,14 +8280,13 @@ "synergies": [ "Fading", "Counters Matter", - "Enchantments Matter", - "Interaction" + "Enchantments Matter" ], "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": [ @@ -8320,14 +8309,13 @@ "synergies": [ "Fade Counters", "Counters Matter", - "Enchantments Matter", - "Interaction" + "Enchantments Matter" ], "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": [ @@ -8490,8 +8478,7 @@ "synergies": [ "Big Mana", "Spells Matter", - "Spellslinger", - "Interaction" + "Spellslinger" ], "primary_color": "Green", "secondary_color": "Red", @@ -8604,8 +8591,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", @@ -8658,8 +8645,8 @@ "Banding", "Kithkin Kindred", "Knight Kindred", - "Minotaur Kindred", - "Angel Kindred" + "Partner", + "Minotaur Kindred" ], "primary_color": "White", "secondary_color": "Red", @@ -8940,7 +8927,6 @@ ], "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)", @@ -9346,8 +9332,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", @@ -9902,10 +9888,10 @@ "theme": "God Kindred", "synergies": [ "Indestructible", - "Protection", "Transform", "Midrange", - "Exile Matters" + "Exile Matters", + "Sacrifice Matters" ], "primary_color": "Black", "secondary_color": "White", @@ -9929,8 +9915,8 @@ "synergy_commanders": [ "Toski, Bearer of Secrets - Synergy (Indestructible)", "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Indestructible)", - "Boromir, Warden of the Tower - Synergy (Protection)", - "Avacyn, Angel of Hope - Synergy (Protection)" + "Veyran, Voice of Duality - Synergy (Transform)", + "Rishkar, Peema Renegade - Synergy (Midrange)" ], "popularity_bucket": "Niche", "editorial_quality": "draft", @@ -10088,7 +10074,7 @@ "Novijen Sages" ], "synergy_commanders": [ - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yawgmoth, Thran Physician - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -10486,7 +10472,7 @@ "Ulasht, the Hate Seed - Synergy (Hellion Kindred)", "Thromok the Insatiable - Synergy (Hellion Kindred)", "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)" ], "popularity_bucket": "Common", "editorial_quality": "draft", @@ -10714,10 +10700,10 @@ "theme": "Hexproof", "synergies": [ "Hexproof from", - "Protection", "Stax", - "Interaction", - "Beast Kindred" + "Beast Kindred", + "Elemental Kindred", + "Outlaw Kindred" ], "primary_color": "Green", "secondary_color": "Blue", @@ -10740,9 +10726,9 @@ ], "synergy_commanders": [ "Niv-Mizzet, Guildpact - Synergy (Hexproof from)", - "Toski, Bearer of Secrets - Synergy (Protection)", - "Purphoros, God of the Forge - Synergy (Protection)", - "Kutzil, Malamet Exemplar - Synergy (Stax)" + "Kutzil, Malamet Exemplar - Synergy (Stax)", + "Lotho, Corrupt Shirriff - Synergy (Stax)", + "Loot, Exuberant Explorer - Synergy (Beast Kindred)" ], "popularity_bucket": "Niche", "editorial_quality": "draft", @@ -10752,9 +10738,7 @@ "id": "hexproof-from", "theme": "Hexproof from", "synergies": [ - "Hexproof", - "Protection", - "Interaction" + "Hexproof" ], "primary_color": "Black", "secondary_color": "Green", @@ -10776,10 +10760,7 @@ "Niv-Mizzet, Supreme" ], "synergy_commanders": [ - "Silumgar, the Drifting Death - Synergy (Hexproof)", - "Toski, Bearer of Secrets - Synergy (Protection)", - "Purphoros, God of the Forge - Synergy (Protection)", - "Syr Konrad, the Grim - Synergy (Interaction)" + "Silumgar, the Drifting Death - Synergy (Hexproof)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -10875,9 +10856,9 @@ "synergies": [ "Legends Matter", "Backgrounds Matter", - "Partner", "Choose a background", - "Doctor's companion" + "Doctor's companion", + "Shrines Matter" ], "primary_color": "White", "secondary_color": "Black", @@ -10901,7 +10882,7 @@ "synergy_commanders": [ "Jaheira, Friend of the Forest - Synergy (Backgrounds Matter)", "Karlach, Fury of Avernus - Synergy (Backgrounds Matter)", - "Kodama of the East Tree - Synergy (Partner)" + "Lae'zel, Vlaakith's Champion - Synergy (Choose a background)" ], "popularity_bucket": "Very Common", "editorial_quality": "draft", @@ -10976,7 +10957,7 @@ "Curious Homunculus // Voracious Reader", "Filigree Attendant", "Riddlekeeper", - "Bonded Fetch" + "Zndrsplt, Eye of Wisdom" ], "synergy_commanders": [ "Ragavan, Nimble Pilferer - Synergy (Little Fellas)", @@ -11093,8 +11074,8 @@ "Xiahou Dun, the One-Eyed", "Wu Scout", "Wei Scout", - "Lu Bu, Master-at-Arms", "Wu Light Cavalry", + "Lu Bu, Master-at-Arms", "Guan Yu, Sainted Warrior" ], "synergy_commanders": [ @@ -11234,9 +11215,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)" + "Krenko, Tin Street Kingpin - Synergy (Counters Matter)", + "Yawgmoth, Thran Physician - Synergy (Counters Matter)" ], "example_cards": [ "Dark Depths", @@ -11570,9 +11551,9 @@ "synergies": [ "God Kindred", "Protection", - "Interaction", "Lifegain", - "Life Matters" + "Life Matters", + "Stax" ], "primary_color": "White", "secondary_color": "Black", @@ -11596,7 +11577,8 @@ "synergy_commanders": [ "Birgi, God of Storytelling // Harnfel, Horn of Bounty - Synergy (God Kindred)", "Ojer Taq, Deepest Foundation // Temple of Civilization - Synergy (God Kindred)", - "Syr Konrad, the Grim - Synergy (Interaction)" + "Boromir, Warden of the Tower - Synergy (Protection)", + "Tatyova, Benthic Druid - Synergy (Lifegain)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -11615,25 +11597,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" + "Lae'zel, Vlaakith's Champion", + "Tekuthal, Inquiry Dominus" ], "example_cards": [ "Karn's Bastion", "Doubling Season", "Evolution Sage", "Cankerbloom", - "Etali, Primal Conqueror // Etali, Primal Sickness", "Thrummingbird", "Yawgmoth, Thran Physician", - "Tezzeret's Gambit" + "Tezzeret's Gambit", + "Innkeeper's Talent" ], "synergy_commanders": [ "Skithiryx, the Blight Dragon - Synergy (Poison Counters)", - "Tekuthal, Inquiry Dominus - Synergy (Proliferate)", + "Fynn, the Fangbearer - Synergy (Poison Counters)", "Karumonix, the Rat King - Synergy (Toxic)" ], "popularity_bucket": "Uncommon", @@ -11744,8 +11726,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", @@ -11795,28 +11777,28 @@ "synergies": [ "Removal", "Combat Tricks", - "Protection", "Board Wipes", - "Counterspells" + "Counterspells", + "Soulshift" ], "primary_color": "White", "secondary_color": "Blue", "example_commanders": [ "Syr Konrad, the Grim", - "Toski, Bearer of Secrets", "Purphoros, God of the Forge", - "Etali, Primal Conqueror // Etali, Primal Sickness", - "Boromir, Warden of the Tower" + "Boromir, Warden of the Tower", + "Avacyn, Angel of Hope", + "Padeem, Consul of Innovation" ], "example_cards": [ "Swords to Plowshares", + "Swiftfoot Boots", + "Lightning Greaves", "Path to Exile", "Counterspell", "Blasphemous Act", "Beast Within", - "Bojuka Bog", - "Heroic Intervention", - "Cyclonic Rift" + "Bojuka Bog" ], "synergy_commanders": [ "Ulamog, the Infinite Gyre - Synergy (Removal)", @@ -12326,8 +12308,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)" + "Rishkar, Peema Renegade - Synergy (Counters Matter)", + "Krenko, Tin Street Kingpin - Synergy (Counters Matter)" ], "example_cards": [ "Petalmane Baku", @@ -12622,9 +12604,9 @@ "synergies": [ "Draw Triggers", "Wheels", - "Protection", "Creature Tokens", - "Stax" + "Stax", + "Big Mana" ], "primary_color": "Blue", "secondary_color": "Black", @@ -12651,7 +12633,7 @@ "Sheoldred, the Apocalypse - Synergy (Draw Triggers)", "Selvala, Heart of the Wilds - Synergy (Wheels)", "Niv-Mizzet, Parun - Synergy (Wheels)", - "Toski, Bearer of Secrets - Synergy (Protection)" + "Adeline, Resplendent Cathar - Synergy (Creature Tokens)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -12978,9 +12960,9 @@ "synergies": [ "Historics Matter", "Backgrounds Matter", - "Partner", "Choose a background", - "Doctor's companion" + "Doctor's companion", + "Shrines Matter" ], "primary_color": "White", "secondary_color": "Black", @@ -13004,7 +12986,7 @@ "synergy_commanders": [ "Jaheira, Friend of the Forest - Synergy (Backgrounds Matter)", "Karlach, Fury of Avernus - Synergy (Backgrounds Matter)", - "Kodama of the East Tree - Synergy (Partner)" + "Lae'zel, Vlaakith's Champion - Synergy (Choose a background)" ], "popularity_bucket": "Very Common", "editorial_quality": "draft", @@ -13023,8 +13005,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": [ @@ -13054,8 +13036,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": [ @@ -13709,10 +13691,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" + "Heart of Kiran", + "Adeline, Resplendent Cathar - Synergy (Planeswalkers)" ], "example_cards": [ "Spark Double", @@ -13725,12 +13707,11 @@ "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)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Rishkar, Peema Renegade - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -14013,11 +13994,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)" + "Astrid Peth - Synergy (Card Selection)", + "Francisco, Fowl Marauder - Synergy (Card Selection)" ], "example_cards": [ "Get Lost", @@ -14030,7 +14011,6 @@ "Spyglass Siren" ], "synergy_commanders": [ - "Francisco, Fowl Marauder - Synergy (Card Selection)", "Ragavan, Nimble Pilferer - Synergy (Artifact Tokens)" ], "popularity_bucket": "Rare", @@ -14245,8 +14225,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", @@ -14287,7 +14267,7 @@ "Thalia, Heretic Cathar - Synergy (Soldier Kindred)", "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yawgmoth, Thran Physician - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -14647,8 +14627,8 @@ "Skrelv, Defector Mite", "Vishgraz, the Doomhive", "Ria Ivor, Bane of Bladehold", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Poison Counters)", - "Skithiryx, the Blight Dragon - Synergy (Poison Counters)" + "Skithiryx, the Blight Dragon - Synergy (Poison Counters)", + "Fynn, the Fangbearer - Synergy (Poison Counters)" ], "example_cards": [ "Skrelv, Defector Mite", @@ -14991,7 +14971,7 @@ "Bonny Pall, Clearcutter - Synergy (Giant Kindred)", "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yawgmoth, Thran Physician - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -15053,8 +15033,8 @@ "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)", - "Yahenni, Undying Partisan - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)", + "Heliod, Sun-Crowned - Synergy (Counters Matter)" ], "example_cards": [ "Tragic Slip", @@ -15306,7 +15286,7 @@ "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)" ], "example_cards": [ "Everflowing Chalice", @@ -15319,7 +15299,7 @@ "Bloodhusk Ritualist" ], "synergy_commanders": [ - "Yahenni, Undying Partisan - Synergy (Counters Matter)", + "Heliod, Sun-Crowned - Synergy (Counters Matter)", "Selvala, Heart of the Wilds - Synergy (Blink)" ], "popularity_bucket": "Rare", @@ -15735,7 +15715,6 @@ "Ingenious Infiltrator" ], "synergy_commanders": [ - "Higure, the Still Wind - Synergy (Ninjutsu)", "Lord Skitter, Sewer King - Synergy (Rat Kindred)", "Marrow-Gnawer - Synergy (Rat Kindred)", "Syr Konrad, the Grim - Synergy (Human Kindred)" @@ -15758,20 +15737,20 @@ "secondary_color": "Blue", "example_commanders": [ "Nashi, Moon Sage's Scion", + "Yuriko, the Tiger's Shadow", "Ink-Eyes, Servant of Oni", "Higure, the Still Wind", - "Yuffie, Materia Hunter", - "Yuriko, the Tiger's Shadow - Synergy (Ninja Kindred)" + "Yuffie, Materia Hunter" ], "example_cards": [ "Nashi, Moon Sage's Scion", "Fallen Shinobi", "Prosperous Thief", "Thousand-Faced Shadow", + "Yuriko, the Tiger's Shadow", "Silver-Fur Master", "Silent-Blade Oni", - "Ingenious Infiltrator", - "Ink-Eyes, Servant of Oni" + "Ingenious Infiltrator" ], "synergy_commanders": [ "Lord Skitter, Sewer King - Synergy (Rat Kindred)", @@ -16066,7 +16045,7 @@ "Mondrak, Glory Dominus - Synergy (Phyrexian Kindred)", "Sheoldred, the Apocalypse - Synergy (Phyrexian Kindred)", "Elas il-Kor, Sadistic Pilgrim - Synergy (Phyrexian Kindred)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Rishkar, Peema Renegade - Synergy (Counters Matter)" ], "example_cards": [ "Vat of Rebirth", @@ -16079,7 +16058,7 @@ "Sawblade Scamp" ], "synergy_commanders": [ - "Rishkar, Peema Renegade - Synergy (Counters Matter)", + "Krenko, Tin Street Kingpin - Synergy (Counters Matter)", "Ragavan, Nimble Pilferer - Synergy (Artifacts Matter)" ], "popularity_bucket": "Rare", @@ -16136,7 +16115,7 @@ "Sakashima of a Thousand Faces - Synergy (Clones)", "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yawgmoth, Thran Physician - Synergy (Counters Matter)" ], "popularity_bucket": "Niche", "editorial_quality": "draft", @@ -16368,8 +16347,8 @@ "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)", - "Yahenni, Undying Partisan - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)", + "Heliod, Sun-Crowned - Synergy (Counters Matter)" ], "example_cards": [ "Abzan Falconer", @@ -16638,14 +16617,14 @@ "id": "partner", "theme": "Partner", "synergies": [ + "Partner with", "Pirate Kindred", "Artificer Kindred", - "Outlaw Kindred", - "+1/+1 Counters", - "Toughness Matters" + "First strike", + "Elf Kindred" ], - "primary_color": "White", - "secondary_color": "Black", + "primary_color": "Blue", + "secondary_color": "White", "example_commanders": [ "Kodama of the East Tree", "Sakashima of a Thousand Faces", @@ -16664,16 +16643,16 @@ "Prava of the Steel Legion" ], "synergy_commanders": [ + "Pippin, Warden of Isengard - Synergy (Partner with)", + "Merry, Warden of Isengard - Synergy (Partner with)", + "Pir, Imaginative Rascal - Synergy (Partner with)", "Ragavan, Nimble Pilferer - Synergy (Pirate Kindred)", "Captain Lannery Storm - Synergy (Pirate Kindred)", - "Malcolm, Alluring Scoundrel - Synergy (Pirate Kindred)", - "Loran of the Third Path - Synergy (Artificer Kindred)", - "Sai, Master Thopterist - Synergy (Artificer Kindred)", - "Lotho, Corrupt Shirriff - Synergy (Outlaw Kindred)" + "Loran of the Third Path - Synergy (Artificer Kindred)" ], - "popularity_bucket": "Rare", + "popularity_bucket": "Niche", "editorial_quality": "draft", - "description": "Builds around Partner leveraging synergies with Pirate Kindred and Artificer Kindred." + "description": "Builds around Partner leveraging synergies with Partner with and Pirate Kindred." }, { "id": "partner-father-son", @@ -16695,11 +16674,11 @@ "id": "partner-with", "theme": "Partner with", "synergies": [ + "Partner", "Blink", "Enter the Battlefield", "Leave the Battlefield", - "Warrior Kindred", - "Outlaw Kindred" + "Conditional Draw" ], "primary_color": "Blue", "secondary_color": "Red", @@ -16721,16 +16700,16 @@ "Sam, Loyal Attendant" ], "synergy_commanders": [ + "Kodama of the East Tree - Synergy (Partner)", + "Sakashima of a Thousand Faces - Synergy (Partner)", + "Kediss, Emberclaw Familiar - Synergy (Partner)", "Selvala, Heart of the Wilds - Synergy (Blink)", "Sheoldred, Whispering One - Synergy (Blink)", - "Ojer Taq, Deepest Foundation // Temple of Civilization - Synergy (Blink)", - "Elesh Norn, Mother of Machines - Synergy (Enter the Battlefield)", - "Kodama of the East Tree - Synergy (Enter the Battlefield)", - "Nezahal, Primal Tide - Synergy (Leave the Battlefield)" + "Ojer Taq, Deepest Foundation // Temple of Civilization - Synergy (Enter the Battlefield)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", - "description": "Builds around Partner with leveraging synergies with Blink and Enter the Battlefield." + "description": "Builds around Partner with leveraging synergies with Partner and Blink." }, { "id": "peasant-kindred", @@ -16967,23 +16946,22 @@ "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)" + "Yahenni, Undying Partisan - Synergy (Haste)", + "Kiki-Jiki, Mirror Breaker - 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" + "Everquill Phoenix", + "Ashcloud 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)" @@ -17009,7 +16987,7 @@ "Sheoldred, the Apocalypse", "Elas il-Kor, Sadistic Pilgrim", "Sheoldred, Whispering One", - "Etali, Primal Conqueror // Etali, Primal Sickness" + "Elesh Norn, Grand Cenobite" ], "example_cards": [ "Phyrexian Metamorph", @@ -17044,9 +17022,9 @@ "example_commanders": [ "Baird, Steward of Argive", "Teysa, Envoy of Ghosts", - "Tamiyo, Inquisitive Student // Tamiyo, Seasoned Scholar", "Sivitri, Dragon Master", - "Isperia, Supreme Judge" + "Isperia, Supreme Judge", + "Thantis, the Warweaver" ], "example_cards": [ "Propaganda", @@ -17096,8 +17074,8 @@ "Cid, Freeflier Pilot", "Kotori, Pilot Prodigy", "Tannuk, Memorial Ensign", - "Prodigy's Prototype", - "Defend the Rider" + "Defend the Rider", + "Prodigy's Prototype" ], "synergy_commanders": [ "Sram, Senior Edificer - Synergy (Vehicles)", @@ -17118,7 +17096,7 @@ "Devil Kindred", "Offspring", "Burn", - "Board Wipes" + "Role token" ], "primary_color": "Red", "secondary_color": "Black", @@ -17155,8 +17133,8 @@ "Siren Kindred", "Raid", "Encore", - "Explore", - "Outlaw Kindred" + "Outlaw Kindred", + "Explore" ], "primary_color": "Blue", "secondary_color": "Red", @@ -17382,24 +17360,23 @@ "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" + "Karumonix, the Rat King", + "Ixhel, Scion of Atraxa" ], "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" + "Grafted Exoskeleton", + "Vraska's Fall" ], "synergy_commanders": [ - "Ixhel, Scion of Atraxa - Synergy (Toxic)", "Vishgraz, the Doomhive - Synergy (Mite Kindred)" ], "popularity_bucket": "Uncommon", @@ -17670,7 +17647,7 @@ "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", "Adeline, Resplendent Cathar - Synergy (Planeswalkers)", "Vorinclex, Monstrous Raider - Synergy (Planeswalkers)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)" ], "popularity_bucket": "Niche", "editorial_quality": "draft", @@ -17680,37 +17657,37 @@ "id": "protection", "theme": "Protection", "synergies": [ - "Ward", - "Hexproof", "Indestructible", - "Shroud", - "Divinity Counters" + "Angel Kindred", + "Interaction", + "Knight Kindred", + "Combat Tricks" ], "primary_color": "White", "secondary_color": "Green", "example_commanders": [ - "Toski, Bearer of Secrets", - "Purphoros, God of the Forge", - "Etali, Primal Conqueror // Etali, Primal Sickness", "Boromir, Warden of the Tower", - "Avacyn, Angel of Hope" + "Avacyn, Angel of Hope", + "Yawgmoth, Thran Physician", + "Padeem, Consul of Innovation", + "Yahenni, Undying Partisan" ], "example_cards": [ + "Swiftfoot Boots", + "Lightning Greaves", "Heroic Intervention", "The One Ring", - "Teferi's Protection", - "Roaming Throne", "Boros Charm", "Flawless Maneuver", "Akroma's Will", "Mithril Coat" ], "synergy_commanders": [ - "Adrix and Nev, Twincasters - Synergy (Ward)", - "Miirym, Sentinel Wyrm - Synergy (Ward)", - "Ulamog, the Defiler - Synergy (Ward)", - "General Ferrous Rokiric - Synergy (Hexproof)", - "Silumgar, the Drifting Death - Synergy (Hexproof)" + "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)", + "Syr Konrad, the Grim - Synergy (Interaction)" ], "popularity_bucket": "Very Common", "editorial_quality": "draft", @@ -17894,8 +17871,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", @@ -18263,9 +18240,9 @@ "Swarmyard Massacre" ], "synergy_commanders": [ - "Higure, the Still Wind - Synergy (Ninjutsu)", - "Yuriko, the Tiger's Shadow - Synergy (Ninja Kindred)", + "Yuriko, the Tiger's Shadow - Synergy (Ninjutsu)", "Satoru, the Infiltrator - Synergy (Ninja Kindred)", + "Satoru Umezawa - Synergy (Ninja Kindred)", "Kiora, the Rising Tide - Synergy (Threshold)" ], "popularity_bucket": "Uncommon", @@ -18407,8 +18384,8 @@ "Reanimate", "Faithless Looting", "Victimize", - "Takenuma, Abandoned Mire", "Animate Dead", + "Takenuma, Abandoned Mire", "Syr Konrad, the Grim", "Gray Merchant of Asphodel", "Guardian Project" @@ -18562,7 +18539,7 @@ "Krovikan Rot" ], "synergy_commanders": [ - "Toski, Bearer of Secrets - Synergy (Interaction)" + "Purphoros, God of the Forge - Synergy (Interaction)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -18604,8 +18581,8 @@ "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)", - "Yahenni, Undying Partisan - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)", + "Heliod, Sun-Crowned - Synergy (Counters Matter)" ], "example_cards": [ "Wren's Run Hydra", @@ -18656,7 +18633,7 @@ "synergy_commanders": [ "He Who Hungers - Synergy (Soulshift)", "Syr Konrad, the Grim - Synergy (Interaction)", - "Toski, Bearer of Secrets - Synergy (Interaction)", + "Purphoros, God of the Forge - Synergy (Interaction)", "Lotho, Corrupt Shirriff - Synergy (Control)" ], "popularity_bucket": "Very Common", @@ -18693,7 +18670,7 @@ "Alchemist's Assistant" ], "synergy_commanders": [ - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yawgmoth, Thran Physician - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -18729,7 +18706,7 @@ "Valeron Wardens" ], "synergy_commanders": [ - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -18975,7 +18952,7 @@ "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)" ], "example_cards": [ "Spider-Punk", @@ -18988,7 +18965,7 @@ "Wrecking Beast" ], "synergy_commanders": [ - "Yahenni, Undying Partisan - Synergy (Counters Matter)", + "Heliod, Sun-Crowned - Synergy (Counters Matter)", "Sram, Senior Edificer - Synergy (Voltron)" ], "popularity_bucket": "Rare", @@ -19254,7 +19231,7 @@ "Piper Wright, Publick Reporter - Synergy (Clue Token)", "Tivit, Seller of Secrets - Synergy (Investigate)", "Kellan, Inquisitive Prodigy // Tail the Suspect - Synergy (Investigate)", - "Edgar, Charmed Groom // Edgar Markov's Coffin - Synergy (Blood Token)" + "Old Rutstein - Synergy (Blood Token)" ], "popularity_bucket": "Common", "editorial_quality": "draft", @@ -19366,7 +19343,7 @@ "synergy_commanders": [ "Syr Konrad, the Grim - Synergy (Mill)", "Emry, Lurker of the Loch - Synergy (Mill)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Rishkar, Peema Renegade - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -19563,7 +19540,7 @@ "Sewer Shambler" ], "synergy_commanders": [ - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -20005,7 +19982,7 @@ "Clones", "Flash", "Little Fellas", - "Protection" + "Outlaw Kindred" ], "primary_color": "Blue", "secondary_color": "Green", @@ -20126,8 +20103,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", @@ -20172,11 +20149,10 @@ "id": "shroud", "theme": "Shroud", "synergies": [ - "Protection", - "Interaction", "Toughness Matters", "Big Mana", - "Counters Matter" + "Counters Matter", + "Little Fellas" ], "primary_color": "Green", "secondary_color": "Blue", @@ -20184,8 +20160,8 @@ "Multani, Maro-Sorcerer", "Kodama of the North Tree", "Autumn Willow", - "Toski, Bearer of Secrets - Synergy (Protection)", - "Purphoros, God of the Forge - Synergy (Protection)" + "Azusa, Lost but Seeking - Synergy (Toughness Matters)", + "Sheoldred, the Apocalypse - Synergy (Toughness Matters)" ], "example_cards": [ "Argothian Enchantress", @@ -20198,10 +20174,10 @@ "Neurok Commando" ], "synergy_commanders": [ - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Protection)", - "Syr Konrad, the Grim - Synergy (Interaction)", - "Boromir, Warden of the Tower - Synergy (Interaction)", - "Azusa, Lost but Seeking - Synergy (Toughness Matters)" + "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)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -20215,7 +20191,7 @@ "Outlaw Kindred", "Flying", "Artifacts Matter", - "Little Fellas" + "Toughness Matters" ], "primary_color": "Blue", "example_commanders": [ @@ -20232,8 +20208,8 @@ "Spyglass Siren", "Storm Fleet Negotiator", "Malcolm, the Eyes", - "Oaken Siren", - "Hypnotic Siren" + "Zephyr Singer", + "Oaken Siren" ], "synergy_commanders": [ "Captain Lannery Storm - Synergy (Pirate Kindred)", @@ -20368,8 +20344,8 @@ "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)", - "Yahenni, Undying Partisan - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)", + "Heliod, Sun-Crowned - Synergy (Counters Matter)" ], "example_cards": [ "Arcbound Slith", @@ -21088,8 +21064,8 @@ "Rishkar, Peema Renegade - Synergy (+1/+1 Counters)", "Krenko, Tin Street Kingpin - Synergy (+1/+1 Counters)", "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)", - "Yahenni, Undying Partisan - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)", + "Heliod, Sun-Crowned - Synergy (Counters Matter)" ], "example_cards": [ "Spike Feeder", @@ -21575,7 +21551,7 @@ "Bottomless Vault" ], "synergy_commanders": [ - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Rishkar, Peema Renegade - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -21679,9 +21655,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)" @@ -21757,7 +21733,7 @@ "Shoulder to Shoulder" ], "synergy_commanders": [ - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Heliod, Sun-Crowned - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -22288,7 +22264,7 @@ "Kiora, the Rising Tide", "Far Wanderings", "Barbarian Ring", - "Cabal Pit" + "Putrid Imp" ], "popularity_bucket": "Niche", "editorial_quality": "draft", @@ -22442,8 +22418,8 @@ ], "example_cards": [ "The Tenth Doctor", - "Wibbly-wobbly, Timey-wimey", "Time Beetle", + "Wibbly-wobbly, Timey-wimey", "Rotating Fireplace", "The Parting of the Ways", "All of History, All at Once", @@ -22718,8 +22694,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)" @@ -22795,7 +22771,7 @@ "Yawgmoth, Thran Physician - Synergy (+1/+1 Counters)", "Syr Konrad, the Grim - Synergy (Human Kindred)", "Azusa, Lost but Seeking - Synergy (Human Kindred)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Yahenni, Undying Partisan - Synergy (Counters Matter)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -23092,8 +23068,8 @@ "Shaman Kindred", "Trample", "Warrior Kindred", - "Protection", - "+1/+1 Counters" + "+1/+1 Counters", + "Counters Matter" ], "primary_color": "Green", "secondary_color": "Black", @@ -23131,10 +23107,10 @@ "theme": "Turtle Kindred", "synergies": [ "Ward", - "Protection", "Toughness Matters", "Stax", - "Interaction" + "Little Fellas", + "Big Mana" ], "primary_color": "Blue", "secondary_color": "Green", @@ -23159,9 +23135,9 @@ "Adrix and Nev, Twincasters - Synergy (Ward)", "Miirym, Sentinel Wyrm - Synergy (Ward)", "Ulamog, the Defiler - Synergy (Ward)", - "Toski, Bearer of Secrets - Synergy (Protection)", - "Purphoros, God of the Forge - Synergy (Protection)", - "Azusa, Lost but Seeking - Synergy (Toughness Matters)" + "Azusa, Lost but Seeking - Synergy (Toughness Matters)", + "Sheoldred, the Apocalypse - Synergy (Toughness Matters)", + "Kutzil, Malamet Exemplar - Synergy (Stax)" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -23492,8 +23468,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", @@ -23557,9 +23533,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)" ], @@ -23582,7 +23558,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)", - "Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)" + "Rishkar, Peema Renegade - Synergy (Counters Matter)" ], "example_cards": [ "Dreamtide Whale", @@ -23595,7 +23571,7 @@ "Chronozoa" ], "synergy_commanders": [ - "Rishkar, Peema Renegade - Synergy (Counters Matter)", + "Krenko, Tin Street Kingpin - Synergy (Counters Matter)", "Sram, Senior Edificer - Synergy (Enchantments Matter)" ], "popularity_bucket": "Rare", @@ -23746,9 +23722,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": [ @@ -23936,10 +23912,10 @@ "theme": "Ward", "synergies": [ "Turtle Kindred", - "Protection", "Dinosaur Kindred", "Dragon Kindred", - "Interaction" + "Stax", + "Reach" ], "primary_color": "Blue", "secondary_color": "Green", @@ -23956,17 +23932,17 @@ "Adrix and Nev, Twincasters", "Miirym, Sentinel Wyrm", "Bronze Guardian", - "Hulking Raptor", "Ulamog, the Defiler", + "Hulking Raptor", "Valgavoth, Terror Eater" ], "synergy_commanders": [ "Kogla and Yidaro - Synergy (Turtle Kindred)", "The Pride of Hull Clade - Synergy (Turtle Kindred)", "Archelos, Lagoon Mystic - Synergy (Turtle Kindred)", - "Toski, Bearer of Secrets - Synergy (Protection)", - "Purphoros, God of the Forge - Synergy (Protection)", - "Etali, Primal Storm - Synergy (Dinosaur Kindred)" + "Etali, Primal Storm - Synergy (Dinosaur Kindred)", + "Ghalta, Primal Hunger - Synergy (Dinosaur Kindred)", + "Niv-Mizzet, Parun - Synergy (Dragon Kindred)" ], "popularity_bucket": "Niche", "editorial_quality": "draft", @@ -24631,8 +24607,8 @@ "Minas Morgul, Dark Fortress", "Witch-king of Angmar", "Nazgûl", - "Lord of the Nazgûl", "Accursed Duneyard", + "Lord of the Nazgûl", "In the Darkness Bind Them", "Street Wraith", "Sauron, the Necromancer" @@ -24777,8 +24753,8 @@ "Gray Merchant of Asphodel", "Field of the Dead", "Fanatic of Rhonas", - "Carrion Feeder", "Warren Soultrader", + "Carrion Feeder", "Accursed Marauder", "Stitcher's Supplier", "Fleshbag Marauder" @@ -24834,9 +24810,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)" + "Krenko, Tin Street Kingpin - Synergy (Counters Matter)", + "Yawgmoth, Thran Physician - Synergy (Counters Matter)" ], "example_cards": [ "Dwarven Armorer", @@ -24860,9 +24836,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)" + "Krenko, Tin Street Kingpin - Synergy (Counters Matter)", + "Yawgmoth, Thran Physician - Synergy (Counters Matter)" ], "example_cards": [ "Dwarven Armorer", @@ -24872,7 +24848,7 @@ "Clockwork Beast", "Clockwork Avian", "Consuming Ferocity", - "Clockwork Swarm" + "Balduvian Hydra" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -24893,9 +24869,9 @@ "Dwarven Armory", "Tin-Wing Chimera", "Brass-Talon Chimera", - "Fungus Elemental", "Iron-Heart Chimera", - "Lead-Belly Chimera" + "Lead-Belly Chimera", + "Fungus Elemental" ], "popularity_bucket": "Rare", "editorial_quality": "draft", @@ -24930,7 +24906,7 @@ "Cantrips": 88, "Card Draw": 309, "Combat Tricks": 214, - "Interaction": 1056, + "Interaction": 904, "Unconditional Draw": 133, "Bending": 5, "Cost Reduction": 68, @@ -24956,7 +24932,7 @@ "Sloth Kindred": 3, "Lands Matter": 169, "Gargoyle Kindred": 11, - "Protection": 331, + "Protection": 231, "Griffin Kindred": 43, "Cleric Kindred": 365, "Backgrounds Matter": 11, @@ -25020,8 +24996,6 @@ "Lifegain Triggers": 37, "Hero Kindred": 24, "Stun Counters": 5, - "Take 59 Flights of Stairs": 1, - "Take the Elevator": 1, "Pilot Kindred": 18, "Artificer Kindred": 49, "Energy": 21, @@ -25046,7 +25020,7 @@ "Loot": 71, "Haste": 1, "Trample": 15, - "Partner": 9, + "Partner": 16, "Dragon Kindred": 27, "Land Types Matter": 40, "Phyrexian Kindred": 64, @@ -25142,7 +25116,6 @@ "Noble Kindred": 23, "Spell Copy": 10, "Storm": 3, - "Brand-new Sky": 1, "Card Selection": 7, "Explore": 7, "Eye Kindred": 4, @@ -25169,12 +25142,10 @@ "Shapeshifter Kindred": 9, "Boast": 4, "Detain": 5, - "Wind Walk": 1, "Miracle": 6, "Doctor Kindred": 10, "Doctor's Companion": 8, "Doctor's companion": 8, - "History Teacher": 1, "Thopter Kindred": 3, "Ox Kindred": 13, "Extort": 4, @@ -25184,12 +25155,7 @@ "Myriad": 5, "Treasure": 11, "Treasure Token": 13, - "Ability": 1, - "Attack": 1, - "Item": 1, - "Magic": 1, "Finality Counters": 2, - "Lure the Unwary": 1, "Insect Kindred": 6, "Bat Kindred": 11, "Enrage": 3, @@ -25241,7 +25207,6 @@ "Detective Kindred": 17, "Bestow": 11, "Omen Counters": 1, - "Healing Tears": 1, "Retrace": 1, "Champion": 2, "Sweep": 2, @@ -25263,7 +25228,6 @@ "Cumulative upkeep": 13, "Hideaway": 3, "Inkling Kindred": 1, - "Crash Landing": 1, "Impulse": 3, "Junk Token": 1, "Junk Tokens": 2, @@ -25287,7 +25251,6 @@ "Evoke": 7, "Demigod Kindred": 1, "Chimera Kindred": 1, - "Mold Earth": 1, "Fade Counters": 2, "Fading": 2, "Astartes Kindred": 6, @@ -25295,14 +25258,12 @@ "God Kindred": 11, "Delay Counters": 1, "Exert": 7, - "Dragonfire Dive": 1, "Jackal Kindred": 1, "Freerunning": 1, "Intervention Counters": 1, "Toy Kindred": 4, "Sculpture Kindred": 1, "Prowess": 5, - "Gae Bolg": 1, "Bracket:GameChanger": 6, "Coyote Kindred": 1, "Aftermath": 1, @@ -25339,13 +25300,11 @@ "Storage Counters": 2, "Madness": 2, "Healing Counters": 2, - "The Allagan Eye": 1, "Squad": 5, "Map Token": 1, "Spell mastery": 3, "Meld": 1, "Gith Kindred": 2, - "Psychic Defense": 1, "Basic landcycling": 2, "Landcycling": 2, "For Mirrodin!": 5, @@ -25357,7 +25316,6 @@ "Plainswalk": 2, "Powerstone Token": 4, "Demon Kindred": 3, - "Rites of Banishment": 1, "Training": 5, "Horsemanship": 7, "Snake Kindred": 1, @@ -25375,11 +25333,9 @@ "Hoofprint Counters": 1, "Monstrosity": 4, "Soulshift": 5, - "Science Teacher": 1, "Scientist Kindred": 2, "Javelin Counters": 1, "Credit Counters": 1, - "Protection Fighting Style": 1, "Tiefling Kindred": 1, "Connive": 2, "Ascend": 6, @@ -25410,7 +25366,6 @@ "Aegis Counters": 1, "Read Ahead": 2, "Quest Counters": 6, - "Machina": 1, "Reprieve Counters": 1, "Germ Kindred": 1, "Living weapon": 1, @@ -25429,7 +25384,6 @@ "Carrion Counters": 1, "Behold": 1, "Impending": 1, - "First Contact": 1, "Synth Kindred": 1, "Forecast": 5, "Fungus Kindred": 1, @@ -25448,21 +25402,14 @@ "Study Counters": 1, "Isolation Counters": 1, "Coward Kindred": 1, - "Natural Shelter": 1, - "Cura": 1, - "Curaga": 1, - "Cure": 1, "Egg Kindred": 1, - "Bad Wolf": 1, "Wolf Kindred": 2, "Parley": 1, "\\+0/\\+1 Counters": 3, - "Keen Sight": 1, "Training Counters": 1, "Verse Counters": 2, "Shade Kindred": 1, "Shaman Kindred": 1, - "The Nuka-Cola Challenge": 1, "Blood Token": 1, "Zubera Kindred": 1, "Illusion Kindred": 2, @@ -25471,8 +25418,6 @@ "Soltari Kindred": 9, "Echo Counters": 1, "Feather Counters": 1, - "Grav-cannon": 1, - "Concealed Position": 1, "Intimidate": 1, "Reflection Kindred": 1, "Story Counters": 1, @@ -25481,66 +25426,55 @@ "Harpy Kindred": 1, "Recover": 1, "Ripple": 1, - "Brave Heart": 1, "Tempest Hawk": 1, "Tempting offer": 2, "Collect evidence": 1, "Enlightened Counters": 1, "Time Travel": 2, - "Crushing Teeth": 1, "Currency Counters": 1, "Trap Counters": 1, "Companion": 1, - "Praesidium Protectiva": 1, "Hyena Kindred": 1, "Cloak": 2, "Manifest dread": 1, "Bear Kindred": 1, - "Blessing of Light": 1, - "Aegis of the Emperor": 1, "Custodes Kindred": 1, "Berserker Kindred": 1, "Invitation Counters": 1, - "Look to the Stars": 1, "Monger Kindred": 1, - "Ice Counters": 1, - "Wild Card": 1, - "Call for Aid": 1, - "Stall for Time": 1, - "Pray for Protection": 1, - "Strike a Deal": 1 + "Ice Counters": 1 }, "blue": { - "Blink": 569, - "Enter the Battlefield": 569, + "Blink": 573, + "Enter the Battlefield": 573, "Guest Kindred": 3, - "Human Kindred": 540, - "Leave the Battlefield": 569, - "Little Fellas": 1430, - "Outlaw Kindred": 217, - "Rogue Kindred": 150, + "Human Kindred": 546, + "Leave the Battlefield": 573, + "Little Fellas": 1439, + "Outlaw Kindred": 219, + "Rogue Kindred": 151, "Casualty": 5, "Spell Copy": 78, - "Spells Matter": 1722, - "Spellslinger": 1722, + "Spells Matter": 1726, + "Spellslinger": 1726, "Topdeck": 414, "Bird Kindred": 148, - "Flying": 767, - "Toughness Matters": 905, - "Aggro": 894, + "Flying": 771, + "Toughness Matters": 908, + "Aggro": 898, "Aristocrats": 119, - "Auras": 346, - "Combat Matters": 894, - "Enchant": 303, - "Enchantments Matter": 733, + "Auras": 347, + "Combat Matters": 898, + "Enchant": 305, + "Enchantments Matter": 735, "Midrange": 54, "Sacrifice Matters": 110, "Theft": 114, - "Voltron": 595, - "Big Mana": 1217, + "Voltron": 598, + "Big Mana": 1224, "Elf Kindred": 11, - "Mill": 562, - "Reanimate": 493, + "Mill": 564, + "Reanimate": 495, "Shaman Kindred": 11, "Horror Kindred": 48, "Insect Kindred": 7, @@ -25550,16 +25484,15 @@ "Manifest dread": 9, "Control": 666, "Counterspells": 348, - "Interaction": 896, - "Stax": 914, + "Interaction": 793, + "Stax": 915, "Fish Kindred": 43, - "Flash": 167, - "Probing Telepathy": 1, - "Protection": 158, + "Flash": 169, "Ward": 39, + "Protection": 64, "Threshold": 9, - "Historics Matter": 289, - "Legends Matter": 289, + "Historics Matter": 292, + "Legends Matter": 292, "Noble Kindred": 13, "Octopus Kindred": 42, "Removal": 249, @@ -25570,28 +25503,28 @@ "Scion Kindred": 6, "Token Creation": 271, "Tokens Matter": 272, - "+1/+1 Counters": 221, - "Counters Matter": 475, + "+1/+1 Counters": 223, + "Counters Matter": 478, "Drake Kindred": 75, "Kicker": 29, - "Card Draw": 1046, - "Discard Matters": 325, - "Loot": 245, - "Wizard Kindred": 525, + "Card Draw": 1050, + "Discard Matters": 326, + "Loot": 246, + "Wizard Kindred": 526, "Cost Reduction": 144, - "Artifacts Matter": 620, + "Artifacts Matter": 621, "Equipment Matters": 90, "Lands Matter": 198, - "Conditional Draw": 194, + "Conditional Draw": 196, "Defender": 69, - "Draw Triggers": 170, + "Draw Triggers": 171, "Wall Kindred": 41, - "Wheels": 210, + "Wheels": 211, "Artifact Tokens": 107, "Thopter Kindred": 17, - "Cantrips": 191, - "Unconditional Draw": 448, - "Board Wipes": 55, + "Cantrips": 192, + "Unconditional Draw": 449, + "Board Wipes": 56, "Bracket:MassLandDenial": 8, "Equipment": 25, "Reconfigure": 3, @@ -25602,17 +25535,16 @@ "Doctor Kindred": 9, "Doctor's Companion": 7, "Doctor's companion": 6, - "Ultimate Sacrifice": 1, "Drone Kindred": 22, "Zombie Kindred": 83, "Turtle Kindred": 21, "Avatar Kindred": 14, - "Exile Matters": 140, + "Exile Matters": 141, "Suspend": 24, "Time Counters": 32, "Impulse": 11, - "Soldier Kindred": 80, - "Combat Tricks": 129, + "Soldier Kindred": 83, + "Combat Tricks": 131, "Strive": 4, "Cleric Kindred": 24, "Enchantment Tokens": 11, @@ -25620,7 +25552,7 @@ "Life Matters": 38, "Lifegain": 38, "Beast Kindred": 47, - "Elemental Kindred": 109, + "Elemental Kindred": 110, "Toolbox": 70, "Energy": 24, "Energy Counters": 22, @@ -25630,34 +25562,35 @@ "Politics": 43, "Servo Kindred": 1, "Vedalken Kindred": 55, - "Burn": 78, + "Burn": 79, "Max speed": 4, "Start your engines!": 4, "Scry": 138, "X Spells": 109, - "Shapeshifter Kindred": 57, + "Shapeshifter Kindred": 58, "Evoke": 6, "Leviathan Kindred": 21, "Whale Kindred": 17, "Detective Kindred": 20, "Sphinx Kindred": 61, "Renew": 3, - "Advisor Kindred": 31, + "Advisor Kindred": 32, "Merfolk Kindred": 215, "Robot Kindred": 20, - "Stun Counters": 45, + "Stun Counters": 46, "Cleave": 4, "Spellshaper Kindred": 11, "Reflection Kindred": 2, "Storm": 9, "Time Travel": 3, "Domain": 6, - "Siren Kindred": 19, + "Siren Kindred": 20, "Backgrounds Matter": 13, "Choose a background": 7, "Halfling Kindred": 1, - "Partner with": 8, - "Vigilance": 49, + "Partner": 17, + "Partner with": 9, + "Vigilance": 50, "Bracket:ExtraTurn": 29, "Foretell": 13, "God Kindred": 8, @@ -25666,7 +25599,7 @@ "Frog Kindred": 20, "Salamander Kindred": 8, "Encore": 4, - "Pirate Kindred": 67, + "Pirate Kindred": 68, "Warrior Kindred": 44, "Treasure": 13, "Treasure Token": 15, @@ -25680,17 +25613,16 @@ "Dragon Kindred": 45, "Elder Kindred": 4, "Hexproof": 21, - "Faerie Kindred": 80, + "Faerie Kindred": 81, "Mana Dork": 47, "Morph": 43, - "Pingers": 22, + "Pingers": 23, "Flood Counters": 3, "Manifestation Counters": 1, - "Clones": 143, + "Clones": 145, "Cipher": 7, "Prototype": 4, "Learn": 4, - "Aura Swap": 1, "Mutate": 5, "Monarch": 8, "Quest Counters": 4, @@ -25701,9 +25633,9 @@ "Metalcraft": 8, "Addendum": 3, "Heroic": 10, - "Convoke": 10, + "Convoke": 11, "Angel Kindred": 3, - "Spirit Kindred": 148, + "Spirit Kindred": 149, "Nightmare Kindred": 17, "Role token": 6, "Infect": 34, @@ -25713,11 +25645,10 @@ "Incubate": 4, "Incubator Token": 4, "Phyrexian Kindred": 51, - "Project Image": 1, "Hero Kindred": 7, "Job select": 4, "Oil Counters": 12, - "Alien Kindred": 7, + "Alien Kindred": 8, "Planeswalkers": 72, "Superfriends": 72, "Amass": 13, @@ -25758,7 +25689,7 @@ "Mana Rock": 22, "Overload": 6, "Haste": 2, - "Homunculus Kindred": 20, + "Homunculus Kindred": 21, "Rooms Matter": 12, "Card Selection": 10, "Explore": 10, @@ -25770,7 +25701,6 @@ "Phasing": 10, "Converge": 4, "Hag Kindred": 2, - "Partner": 8, "Corrupted": 2, "Clash": 7, "Madness": 7, @@ -25812,7 +25742,7 @@ "Awaken": 5, "Undaunted": 1, "Kavu Kindred": 2, - "Golem Kindred": 4, + "Golem Kindred": 5, "Warp": 7, "Lhurgoyf Kindred": 1, "Pillowfort": 4, @@ -25865,7 +25795,6 @@ "Bargain": 5, "Warlock Kindred": 8, "Behold": 1, - "Avoidance": 1, "Exploit": 8, "Transmute": 6, "Plot": 10, @@ -25881,8 +25810,7 @@ "Trilobite Kindred": 3, "Freerunning": 2, "Tiefling Kindred": 2, - "Two-Headed Coin": 1, - "Monk Kindred": 19, + "Monk Kindred": 20, "Pilot Kindred": 7, "Multikicker": 3, "Glimmer Kindred": 2, @@ -25911,10 +25839,7 @@ "Exalted": 2, "Hippogriff Kindred": 2, "Assist": 4, - "Neurotraumal Rod": 1, "Tyranid Kindred": 2, - "Children of the Cult": 1, - "Genestealer's Kiss": 1, "Infection Counters": 1, "Powerstone Token": 6, "Undying": 4, @@ -25930,7 +25855,6 @@ "Nymph Kindred": 4, "Forecast": 3, "Crocodile Kindred": 3, - "Aberrant Tinkering": 1, "Germ Kindred": 1, "Samurai Kindred": 1, "Incarnation Kindred": 3, @@ -25938,17 +25862,13 @@ "Efreet Kindred": 4, "Horsemanship": 7, "Demon Kindred": 2, - "Discover": 2, + "Discover": 3, "Tide Counters": 2, "Camarid Kindred": 1, "Weird Kindred": 4, "Ooze Kindred": 2, - "Blizzaga": 1, - "Blizzara": 1, - "Blizzard": 1, "Ice Counters": 3, "Lizard Kindred": 4, - "Ceremorphosis": 1, "First strike": 3, "Split second": 5, "Detain": 3, @@ -25960,22 +25880,17 @@ "Graveyard Matters": 5, "Loyalty Counters": 7, "Compleated": 1, - "Replacement Draw": 2, + "Replacement Draw": 3, "Cost Scaling": 5, "Modal": 5, "Spree": 5, - "Come Fly With Me": 1, "Convert": 1, "Living metal": 1, "More Than Meets the Eye": 1, "Praetor Kindred": 3, - "Confounding Clouds": 1, - "Affirmative": 1, - "Negative": 1, "Experience Counters": 1, "Exhaust": 6, "Indestructible": 3, - "Homunculus Servant": 1, "Kithkin Kindred": 1, "Flanking": 1, "Minotaur Kindred": 1, @@ -25983,7 +25898,6 @@ "Treasure Counters": 1, "Verse Counters": 3, "Grandeur": 1, - "Architect of Deception": 1, "Lieutenant": 2, "Hatchling Counters": 1, "Werewolf Kindred": 1, @@ -25994,7 +25908,6 @@ "Lifegain Triggers": 1, "Lifeloss": 1, "Lifeloss Triggers": 1, - "Woman Who Walked the Earth": 1, "Basic landcycling": 2, "Fateseal": 2, "Rabbit Kindred": 2, @@ -26010,10 +25923,8 @@ "Divinity Counters": 1, "Tentacle Kindred": 2, "Synth Kindred": 2, - "Bigby's Hand": 1, "Fox Kindred": 1, "Annihilator": 1, - "Sonic Booster": 1, "Foreshadow Counters": 1, "Paradox": 2, "Impending": 1, @@ -26030,12 +25941,10 @@ "Ape Kindred": 1, "Page Counters": 1, "Constellation": 6, - "Blue Magic": 1, "Ranger Kindred": 3, "Echo": 1, "Demonstrate": 1, "Dwarf Kindred": 1, - "Hagneia": 1, "Backup": 1, "Monger Kindred": 1, "Storage Counters": 2, @@ -26045,11 +25954,9 @@ "Troll Kindred": 1, "Lifelink": 1, "Hideaway": 3, - "Benediction of the Omnissiah": 1, "Squad": 2, "Starfish Kindred": 2, "Tribute": 1, - "Psychic Abomination": 1, "Slith Kindred": 1, "Slime Counters": 1, "Elk Kindred": 2, @@ -26075,28 +25982,23 @@ "Tempting offer": 1, "Juggernaut Kindred": 1, "Thalakos Kindred": 7, - "Water Always Wins": 1, "Knowledge Counters": 1, "Sponge Kindred": 2, "Minion Kindred": 1, - "Parallel Universe": 1, "Rejection Counters": 1, "Secret council": 1, "Adamant": 3, - "Sleight of Hand": 1, "Toy Kindred": 1, "Toxic": 1, "Harmonize": 3, "Possession Counters": 1, "Astartes Kindred": 1, - "Suppressing Fire": 1, "Sleep Counters": 1, "Hexproof from": 1, "Menace": 1, - "Gust of Wind": 1, "Coin Counters": 1, "Archer Kindred": 1, - "Hive Mind": 1 + "Body-print": 1 }, "black": { "Blink": 757, @@ -26128,7 +26030,7 @@ "Token Creation": 415, "Tokens Matter": 416, "Combat Tricks": 174, - "Interaction": 873, + "Interaction": 805, "Midrange": 69, "Horror Kindred": 184, "Basic landcycling": 2, @@ -26157,7 +26059,7 @@ "Trample": 54, "Specter Kindred": 21, "Centaur Kindred": 3, - "Protection": 93, + "Protection": 51, "Warrior Kindred": 168, "Intimidate": 13, "Spirit Kindred": 145, @@ -26249,7 +26151,6 @@ "Corrupted": 7, "Infect": 59, "Poison Counters": 48, - "Lord of the Pyrrhian Legions": 1, "Necron Kindred": 25, "Beast Kindred": 37, "Frog Kindred": 8, @@ -26265,16 +26166,14 @@ "Oil Counters": 3, "Archon Kindred": 1, "Backup": 4, - "Endurant": 1, "Squad": 3, "Noble Kindred": 31, - "Starscourge": 1, "Blood Token": 27, "Life to Draw": 8, "Planeswalkers": 58, "Superfriends": 58, "Golem Kindred": 5, - "Partner": 8, + "Partner": 15, "Thrull Kindred": 22, "\\+1/\\+2 Counters": 1, "Flashback": 22, @@ -26344,9 +26243,6 @@ "Equip": 32, "Equipment": 35, "Job select": 4, - "Buy Information": 1, - "Hire a Mercenary": 1, - "Sell Contraband": 1, "Treasure": 47, "Treasure Token": 49, "Treefolk Kindred": 6, @@ -26408,7 +26304,6 @@ "Freerunning": 6, "Buyback": 9, "Choose a background": 6, - "Tunnel Snakes Rule!": 1, "Undying": 8, "Flanking": 4, "Changeling": 8, @@ -26431,12 +26326,10 @@ "Nymph Kindred": 3, "Mutate": 5, "Hideaway": 2, - "Animate Chains": 1, "Finality Counters": 11, "Suspend": 11, "Time Counters": 14, "Escape": 10, - "Atomic Transmutation": 1, "Fathomless descent": 3, "Wither": 6, "Goat Kindred": 3, @@ -26472,9 +26365,7 @@ "Earthbending": 1, "Dredge": 6, "Dalek Kindred": 4, - "Exterminate!": 1, "Spell mastery": 4, - "Chaosbringer": 1, "Offspring": 4, "Dauthi Kindred": 11, "Shadow": 15, @@ -26555,9 +26446,7 @@ "Devour": 3, "Forage": 1, "Exploit": 12, - "Flesh Flayer": 1, "Gremlin Kindred": 2, - "Transfigure": 1, " Blood Counters": 1, "Investigate": 8, "Inspired": 5, @@ -26567,8 +26456,6 @@ "Max speed": 6, "Start your engines!": 8, "Manifest": 7, - "Death Ray": 1, - "Disintegration Ray": 1, "Vigilance": 1, "Channel": 3, "Gold Token": 2, @@ -26588,7 +26475,6 @@ "Meld": 1, "Lamia Kindred": 2, "Scout Kindred": 9, - "Reverberating Summons": 1, "-0/-2 Counters": 2, "Evoke": 5, "Dinosaur Kindred": 8, @@ -26597,16 +26483,12 @@ "Level Counters": 4, "Level Up": 4, "Ritual Counters": 1, - "Multi-threat Eliminator": 1, "Discover": 2, "Ki Counters": 2, "Boar Kindred": 3, "Exhaust": 1, "Soul Counters": 4, "Monstrosity": 3, - "Secrets of the Soul": 1, - "Grand Strategist": 1, - "Phaeron": 1, "Demonstrate": 1, "Kirin Kindred": 1, "Manifest dread": 2, @@ -26614,7 +26496,6 @@ "Modal": 4, "Spree": 4, "Body Thief": 1, - "Devour Intellect": 1, "Battles Matter": 4, "Efreet Kindred": 1, "Jump": 1, @@ -26627,11 +26508,8 @@ "Hippo Kindred": 1, "Myr Kindred": 2, "Persist": 4, - "Enmitic Exterminator": 1, "Undergrowth": 4, - "Guardian Protocols": 1, "Mannequin Counters": 1, - "Bad Breath": 1, "Plant Kindred": 2, "Manticore Kindred": 1, "Hit Counters": 2, @@ -26639,26 +26517,19 @@ "Hour Counters": 1, "Processor Kindred": 2, "Awaken": 3, - "Mold Harvest": 1, "Nautilus Kindred": 1, "Rigger Kindred": 1, "Astartes Kindred": 4, "Primarch Kindred": 1, - "Primarch of the Death Guard": 1, "Divinity Counters": 1, - "Psychic Blades": 1, "Feeding Counters": 1, "Multiple Copies": 4, "Nazgûl": 1, "Atog Kindred": 1, - "Synaptic Disintegrator": 1, - "Relentless March": 1, "Aftermath": 1, "Epic": 1, "Kinship": 2, "Revival Counters": 1, - "Mutsunokami": 1, - "Weird Insight": 1, "Weird Kindred": 1, "Scarecrow Kindred": 3, "Eon Counters": 1, @@ -26671,15 +26542,9 @@ "Offering": 1, "Depletion Counters": 1, "Carrier Kindred": 5, - "Rot Fly": 1, - "Dynastic Advisor": 1, - "Curse of the Walking Pox": 1, - "Executioner Round": 1, - "Hyperfrag Round": 1, "Mayhem": 3, "Magecraft": 2, "Populate": 1, - "Harbinger of Despair": 1, "Octopus Kindred": 2, "Starfish Kindred": 2, "Kithkin Kindred": 1, @@ -26687,73 +26552,43 @@ "Retrace": 2, "Mole Kindred": 1, "Relentless Rats": 1, - "Decayed": 1, "Kraken Kindred": 1, "Blight Counters": 1, - "Phalanx Commander": 1, - "Blood Chalice": 1, - "Elite Troops": 1, "Monger Kindred": 1, "Coward Kindred": 1, "Serf Kindred": 1, - "Super Nova": 1, "Shadowborn Apostle": 1, "C'tan Kindred": 2, - "Drain Life": 1, - "Matter Absorption": 1, - "Spear of the Void Dragon": 1, "Join forces": 1, "Surrakar Kindred": 2, "Tribute": 1, "Ape Kindred": 2, "Sweep": 1, - "Hyperphase Threshers": 1, - "Command Protocols": 1, "Snail Kindred": 1, "Cascade": 1, - "Jolly Gutpipes": 1, "Spike Kindred": 1, "Mite Kindred": 1, - "Blood Drain": 1, "Ripple": 1, - "My Will Be Done": 1, - "The Seven-fold Chant": 1, "Bracket:ExtraTurn": 1, "Tempting offer": 1, "Prey Counters": 1, "Firebending": 1, "Necrodermis Counters": 1, "Varmint Kindred": 1, - "Consume Anomaly": 1, "Stash Counters": 1, "Pegasus Kindred": 1, - "Chef's Knife": 1, "Stun Counters": 2, "Plague Counters": 2, - "Prismatic Gallery": 1, - "Dynastic Codes": 1, - "Targeting Relay": 1, "Demigod Kindred": 1, - "Horrific Symbiosis": 1, "Chroma": 1, "Barbarian Kindred": 2, - "Rat Tail": 1, - "Devourer of Souls": 1, - "Spiked Retribution": 1, - "Death Gigas": 1, - "Galian Beast": 1, - "Hellmasker": 1, - "Deal with the Black Guardian": 1, "Doctor Kindred": 1, "Doctor's Companion": 1, "Doctor's companion": 1, "Compleated": 1, - "Toxic Spores": 1, "Wish Counters": 1, "Camel Kindred": 1, - "Petrification Counters": 1, - "Burning Chains": 1, - "My First Friend": 1 + "Petrification Counters": 1 }, "red": { "Burn": 1537, @@ -26770,7 +26605,7 @@ "Combat Matters": 1406, "Combat Tricks": 160, "Discard Matters": 303, - "Interaction": 652, + "Interaction": 631, "Madness": 18, "Mill": 341, "Reanimate": 261, @@ -26792,7 +26627,6 @@ "Warrior Kindred": 363, "Cantrips": 79, "Draw Triggers": 54, - "Heavy Rock Cutter": 1, "Tyranid Kindred": 4, "Wheels": 58, "+1/+1 Counters": 248, @@ -26857,7 +26691,7 @@ "Equipment Matters": 141, "Samurai Kindred": 20, "Shaman Kindred": 175, - "Protection": 31, + "Protection": 20, "Conditional Draw": 42, "Phyrexian Kindred": 44, "Ally Kindred": 19, @@ -26877,7 +26711,7 @@ "Wizard Kindred": 94, "Treasure": 108, "Treasure Token": 111, - "Partner": 7, + "Partner": 15, "-1/-1 Counters": 27, "Infect": 7, "Ore Counters": 33, @@ -26931,7 +26765,6 @@ "Flash": 30, "Astartes Kindred": 5, "Demon Kindred": 15, - "Ruinous Ascension": 1, "Amass": 11, "Army Kindred": 10, "Robot Kindred": 18, @@ -26986,7 +26819,6 @@ "Horror Kindred": 13, "Celebration": 5, "Wurm Kindred": 4, - "Scorching Ray": 1, "God Kindred": 10, "Metalcraft": 6, "Hellbent": 7, @@ -26997,15 +26829,12 @@ "Offering": 2, "Flanking": 6, "Knight Kindred": 54, - "Blow Up": 1, "Strive": 4, "Construct Kindred": 13, "Prototype": 4, "Fight": 16, "Bloodthirst": 8, - "Crown of Madness": 1, "Delirium": 12, - "Devastating Charge": 1, "Unleash": 5, "Ooze Kindred": 4, "Wolverine Kindred": 7, @@ -27081,7 +26910,6 @@ "Shapeshifter Kindred": 5, "Harmonize": 3, "Imp Kindred": 2, - "Lord of Chaos": 1, "Fury Counters": 1, "Peasant Kindred": 6, "Rat Kindred": 8, @@ -27118,7 +26946,6 @@ "Coyote Kindred": 1, "Gold Token": 2, "Hero Kindred": 11, - "Gift of Chaos": 1, "Warlock Kindred": 9, "Beholder Kindred": 1, "Monstrosity": 7, @@ -27149,7 +26976,6 @@ "Dragon's Approach": 1, "Multiple Copies": 2, "Surveil": 2, - "Hunters for Hire": 1, "Quest Counters": 5, "\\+0/\\+1 Counters": 1, "\\+2/\\+2 Counters": 1, @@ -27172,7 +26998,6 @@ "Dethrone": 4, "Escape": 5, "Powerstone Token": 5, - "Bio-plasmic Barrage": 1, "Ravenous": 1, "Cloak": 1, "Spell mastery": 3, @@ -27189,10 +27014,6 @@ "Manifest": 4, "Chroma": 3, "Bracket:ExtraTurn": 3, - "Enthralling Performance": 1, - "Fira": 1, - "Firaga": 1, - "Fire": 1, "Bending": 5, "Firebending": 5, "Snake Kindred": 1, @@ -27230,10 +27051,8 @@ "Wither": 6, "Embalm": 1, "Pressure Counters": 1, - "Locus of Slaanesh": 1, "Emerge": 1, "Annihilator": 1, - "Slivercycling": 1, "Hyena Kindred": 2, "Recover": 1, "Doom Counters": 2, @@ -27242,35 +27061,26 @@ "Eerie": 1, "Clue Token": 3, "Investigate": 3, - "Vicious Mockery": 1, "Imprint": 1, "Battles Matter": 5, "Alien Kindred": 3, "Blitz": 8, "Converge": 2, "Void": 3, - "Symphony of Pain": 1, "Vanishing": 2, - "Berzerker": 1, - "Sigil of Corruption": 1, - "The Betrayer": 1, "Venture into the dungeon": 2, "Amplify": 1, - "Frenzied Rampage": 1, "Rhino Kindred": 2, "Forestwalk": 1, "Serpent Kindred": 2, "Assist": 2, "Spectacle": 3, - "Loud Ruckus": 1, "Lieutenant": 3, "Scorpion Kindred": 2, "Stun Counters": 1, "Delve": 1, "Join forces": 1, "Illusion Kindred": 1, - "Detonate": 1, - "Disarm": 1, "Worm Kindred": 2, "Mine Counters": 1, "Performer Kindred": 3, @@ -27282,7 +27092,6 @@ "Kinship": 3, "Divinity Counters": 1, "Banding": 1, - "Sonic Blaster": 1, "Elephant Kindred": 2, "Pangolin Kindred": 1, "Impending": 1, @@ -27290,7 +27099,6 @@ "Squad": 2, "Support": 1, "Plant Kindred": 2, - "Selfie Shot": 1, "Bloodrush": 6, "Replicate": 4, "Porcupine Kindred": 1, @@ -27305,18 +27113,10 @@ "Badger Kindred": 2, "Wage Counters": 1, "Leech Kindred": 1, - "Murasame": 1, "Depletion Counters": 1, - "Bio-Plasmic Scream": 1, - "Family Gathering": 1, - "Family gathering": 1, - "Allure of Slaanesh": 1, - "Fire Cross": 1, "Seven Dwarves": 1, "Dredge": 1, "Mobilize": 3, - "Temporal Foresight": 1, - "Double Overdrive": 1, "Split second": 4, "Grandeur": 2, "Kirin Kindred": 1, @@ -27327,19 +27127,14 @@ "Slith Kindred": 1, "Ember Counters": 1, "Hideaway": 1, - "Mantle of Inspiration": 1, "Ascend": 2, "Ripple": 1, "Synth Kindred": 1, "Vigilance": 2, "Tempting offer": 2, "Read Ahead": 2, - "Advanced Species": 1, "Summon": 1, "Slug Kindred": 1, - "Thundaga": 1, - "Thundara": 1, - "Thunder": 1, "Manifest dread": 2, "Contested Counters": 1, "Epic": 1, @@ -27351,8 +27146,6 @@ "Centaur Kindred": 1, "Token Modification": 1, "Turtle Kindred": 1, - "Bribe the Guards": 1, - "Threaten the Merchant": 1, "Ninja Kindred": 1, "Ninjutsu": 1 }, @@ -27377,8 +27170,7 @@ "Token Creation": 520, "Tokens Matter": 529, "Artifacts Matter": 449, - "Heavy Power Hammer": 1, - "Interaction": 662, + "Interaction": 538, "Little Fellas": 1380, "Mutant Kindred": 27, "Ravenous": 7, @@ -27416,7 +27208,6 @@ "Fight": 74, "Historics Matter": 263, "Legends Matter": 263, - "Nitro-9": 1, "Rebel Kindred": 3, "Equipment Matters": 79, "Reach": 219, @@ -27469,11 +27260,11 @@ "Cycling": 52, "Discard Matters": 87, "Loot": 52, + "Protection": 96, "Vehicles": 25, "Revolt": 6, "Scout Kindred": 97, "Stax": 271, - "Protection": 193, "Faerie Kindred": 13, "Soldier Kindred": 37, "Mount Kindred": 14, @@ -27499,7 +27290,7 @@ "Swampwalk": 10, "Bracket:TutorNonland": 65, "Collect evidence": 6, - "Partner": 8, + "Partner": 13, "Treasure": 26, "Treasure Token": 25, "Turtle Kindred": 12, @@ -27611,7 +27402,6 @@ "Quest Counters": 4, "Delve": 2, "Intimidate": 2, - "Genomic Enhancement": 1, "Wizard Kindred": 22, "Morph": 26, "Drone Kindred": 13, @@ -27652,7 +27442,6 @@ "Provoke": 3, "Sliver Kindred": 18, "Warp": 8, - "Brood Telepathy": 1, "Cleric Kindred": 23, "Ki Counters": 2, "Hippo Kindred": 5, @@ -27690,10 +27479,7 @@ "Acorn Counters": 1, "Bracket:MassLandDenial": 6, "Backup": 6, - "Natural Recovery": 1, - "Proclamator Hailer": 1, "Fateful hour": 2, - "Gathered Swarm": 1, "Cockatrice Kindred": 1, "Pupa Counters": 1, "Ninja Kindred": 4, @@ -27735,7 +27521,6 @@ "Wolverine Kindred": 4, "Pilot Kindred": 4, "Sand Kindred": 2, - "Immune": 1, "Egg Kindred": 2, "Soulbond": 8, "Employee Kindred": 3, @@ -27746,7 +27531,6 @@ "Rabbit Kindred": 10, "Pillowfort": 6, "Nymph Kindred": 4, - "Nonbasic landwalk": 1, "Choose a background": 6, "Endure": 3, "Awaken": 1, @@ -27806,8 +27590,6 @@ "Riot": 3, "Kithkin Kindred": 3, "Slime Counters": 1, - "Devouring Monster": 1, - "Rapacious Hunger": 1, "Replicate": 1, "Demonstrate": 1, "Samurai Kindred": 5, @@ -27815,13 +27597,10 @@ "Mite Kindred": 1, "Depletion Counters": 1, "Cloak": 1, - "Frenzied Metabolism": 1, - "Titanic": 1, "Storage Counters": 2, "Renown": 6, "Embalm": 1, "Boast": 1, - "Endless Swarm": 1, "Undying": 4, "Rat Kindred": 1, "Efreet Kindred": 2, @@ -27836,8 +27615,6 @@ "Graveyard Matters": 2, "Flanking": 1, "Ferret Kindred": 1, - "000 Needles": 1, - "10": 1, "Wither": 3, "Yeti Kindred": 3, "Phasing": 1, @@ -27847,7 +27624,6 @@ "Horsemanship": 1, "Kinship": 3, "Lhurgoyf Kindred": 5, - "Pheromone Trail": 1, "Awakening Counters": 1, "Construct Kindred": 6, "Vitality Counters": 1, @@ -27863,21 +27639,17 @@ "Growth Counters": 2, "Horse Kindred": 9, "Aftermath": 1, - "Infesting Spores": 1, "Divinity Counters": 1, "Harmonize": 3, "Tribute": 3, - "Strategic Coordinator": 1, "Compleated": 1, "Unicorn Kindred": 2, "Nomad Kindred": 1, "Licid Kindred": 2, - "Fast Healing": 1, "Council's dilemma": 3, "Basic landcycling": 3, "Landcycling": 3, "Impending": 1, - "Mama's Coming": 1, "Dethrone": 1, "Will of the Planeswalkers": 1, "Offering": 1, @@ -27889,7 +27661,6 @@ "Rebound": 3, "Ribbon Counters": 1, "Scientist Kindred": 2, - "Vanguard Species": 1, "Camel Kindred": 1, "Wombat Kindred": 1, "Possum Kindred": 2, @@ -27902,20 +27673,15 @@ "Undaunted": 1, "Bracket:ExtraTurn": 1, "Map Token": 2, - "Conjure": 1, - "Conjure Elemental": 1, "Multiple Copies": 1, "Slime Against Humanity": 1, "Slith Kindred": 1, - "Animal May-Ham": 1, "Web-slinging": 2, "Spike Kindred": 10, "Armadillo Kindred": 1, - "Spore Chimney": 1, "Monger Kindred": 1, "Mouse Kindred": 1, "Supply Counters": 1, - "Abraxas": 1, "Ripple": 1, "Replacement Draw": 1, "For Mirrodin!": 1, @@ -27924,23 +27690,16 @@ "Mystic Kindred": 2, "Tempting offer": 1, "Ascend": 2, - "Death Frenzy": 1, "Hatching Counters": 1, "Gold Token": 1, "Read Ahead": 2, - "Bear Witness": 1, - "Final Heaven": 1, - "Meteor Strikes": 1, - "Somersault": 1, "Banding": 1, "Meld": 1, "Velocity Counters": 1, - "Hypertoxic Miasma": 1, "Dash": 1, "Mentor": 1, "Nest Counters": 1, "Toy Kindred": 1, - "Shieldwall": 1, "Freerunning": 1, "Menace": 1, "Processor Kindred": 1, @@ -27948,20 +27707,18 @@ "Praetor Kindred": 3, "-0/-1 Counters": 1, "Scarecrow Kindred": 1, - "Gather Your Courage": 1, - "Run and Hide": 1, "Plainswalk": 1 } }, "generated_from": "merge (analytics + curated YAML + whitelist)", "metadata_info": { "mode": "merge", - "generated_at": "2025-10-07T18:22:00", + "generated_at": "2025-10-09T00:37:26", "curated_yaml_files": 735, "synergy_cap": 5, "inference": "pmi", "version": "phase-b-merge-v1", - "catalog_hash": "ae79af02508e2f9184aa74d63db5c9987fd65cfa87ce7adb50aec2f6ae8397c5" + "catalog_hash": "9f938ea43a438ed3d924bdb971bf4efa34b80fa3728877878729db50c9afa2fd" }, "description_fallback_summary": null } \ No newline at end of file From f2863ef3625c202aa7188b2d60e86f6bd7a78afe Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 9 Oct 2025 17:29:57 -0700 Subject: [PATCH 2/4] feat: complete protection scope filtering with pool limiting --- .env.example | 6 + CHANGELOG.md | 33 + RELEASE_NOTES_TEMPLATE.md | 90 +- _tmp_check_metrics.py | 5 - _tmp_run_orchestrator.py | 3 - code/deck_builder/builder.py | 9 + code/deck_builder/phases/phase4_spells.py | 122 ++- code/deck_builder/phases/phase6_reporting.py | 6 +- code/file_setup/setup.py | 37 +- code/settings.py | 15 +- code/tagging/phasing_scope_detection.py | 206 +++++ code/tagging/protection_grant_detection.py | 146 +++- code/tagging/protection_scope_detection.py | 206 +++++ code/tagging/tag_constants.py | 27 +- code/tagging/tag_utils.py | 78 +- code/tagging/tagger.py | 334 ++++++- code/tests/test_additional_theme_config.py | 2 +- code/tests/test_metadata_partition.py | 300 +++++++ code/web/routes/decks.py | 7 + code/web/templates/base.html | 9 + code/web/templates/partials/deck_summary.html | 2 +- config/themes/theme_list.json | 825 ++++++++---------- docker-compose.yml | 8 +- dockerhub-docker-compose.yml | 6 + 24 files changed, 1924 insertions(+), 558 deletions(-) delete mode 100644 _tmp_check_metrics.py delete mode 100644 _tmp_run_orchestrator.py create mode 100644 code/tagging/phasing_scope_detection.py create mode 100644 code/tagging/protection_scope_detection.py create mode 100644 code/tests/test_metadata_partition.py 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 }}