mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
maintenance: cleaned up, consolidated, and refined codebase for tagging
This commit is contained in:
parent
f2863ef362
commit
0dd5b4cf64
20 changed files with 3191 additions and 2816 deletions
|
|
@ -19,6 +19,9 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
- 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)
|
||||
- **Tagging Module Refactoring**: Large-scale refactor to improve code quality and maintainability
|
||||
- Centralized regex patterns, extracted reusable utilities, decomposed complex functions
|
||||
- Improved code organization and readability while maintaining 100% tagging accuracy
|
||||
|
||||
### Added
|
||||
- Metadata partition system separates diagnostic tags from gameplay themes in card data
|
||||
|
|
@ -42,11 +45,13 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
- 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)
|
||||
- Protection tag renamed to "Protective Effects" throughout web interface to avoid confusion with the Magic keyword "protection"
|
||||
- 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)
|
||||
- Tagging module imports standardized with consistent organization and centralized constants
|
||||
|
||||
### Fixed
|
||||
- Setup progress now shows 100% completion instead of getting stuck at 99%
|
||||
|
|
@ -63,6 +68,9 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
- 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
|
||||
- Cards with static keywords (Protection, Hexproof, Ward, Indestructible) in their keywords field now get proper scope metadata tags
|
||||
- Cards with X in their mana cost now properly identified and tagged with "X Spells" theme for better deck building accuracy
|
||||
- Card tagging system enhanced with smarter pattern detection and more consistent categorization
|
||||
|
||||
## [2.5.2] - 2025-10-08
|
||||
### Summary
|
||||
|
|
|
|||
44
README.md
44
README.md
|
|
@ -101,13 +101,49 @@ Execute saved configs without manual input.
|
|||
Refresh data and caches when formats shift.
|
||||
- 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:
|
||||
- **Force a full rebuild (setup + tagging)**:
|
||||
```powershell
|
||||
docker compose run --rm --entrypoint bash web -lc "python -m code.file_setup.setup"
|
||||
# Docker:
|
||||
docker compose run --rm web python -c "from code.file_setup.setup import initial_setup; from code.tagging.tagger import run_tagging; initial_setup(); run_tagging()"
|
||||
|
||||
# Local (with venv activated):
|
||||
python -c "from code.file_setup.setup import initial_setup; from code.tagging.tagger import run_tagging; initial_setup(); run_tagging()"
|
||||
|
||||
# With parallel processing (faster):
|
||||
python -c "from code.file_setup.setup import initial_setup; from code.tagging.tagger import run_tagging; initial_setup(); run_tagging(parallel=True)"
|
||||
|
||||
# With parallel processing and custom worker count:
|
||||
python -c "from code.file_setup.setup import initial_setup; from code.tagging.tagger import run_tagging; initial_setup(); run_tagging(parallel=True, max_workers=4)"
|
||||
```
|
||||
- Rebuild only the commander catalog:
|
||||
- **Rebuild only CSVs without tagging**:
|
||||
```powershell
|
||||
docker compose run --rm --entrypoint bash web -lc "python -m code.scripts.refresh_commander_catalog"
|
||||
# Docker:
|
||||
docker compose run --rm web python -c "from code.file_setup.setup import initial_setup; initial_setup()"
|
||||
|
||||
# Local:
|
||||
python -c "from code.file_setup.setup import initial_setup; initial_setup()"
|
||||
```
|
||||
- **Run only tagging (CSVs must exist)**:
|
||||
```powershell
|
||||
# Docker:
|
||||
docker compose run --rm web python -c "from code.tagging.tagger import run_tagging; run_tagging()"
|
||||
|
||||
# Local:
|
||||
python -c "from code.tagging.tagger import run_tagging; run_tagging()"
|
||||
|
||||
# With parallel processing (faster):
|
||||
python -c "from code.tagging.tagger import run_tagging; run_tagging(parallel=True)"
|
||||
|
||||
# With parallel processing and custom worker count:
|
||||
python -c "from code.tagging.tagger import run_tagging; run_tagging(parallel=True, max_workers=4)"
|
||||
```
|
||||
- **Rebuild only the commander catalog**:
|
||||
```powershell
|
||||
# Docker:
|
||||
docker compose run --rm web python -m code.scripts.refresh_commander_catalog
|
||||
|
||||
# Local:
|
||||
python -m code.scripts.refresh_commander_catalog
|
||||
```
|
||||
|
||||
### Owned Library
|
||||
|
|
|
|||
|
|
@ -1,61 +1,66 @@
|
|||
# MTG Python Deckbuilder ${VERSION}
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Summary
|
||||
- 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
|
||||
- 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)
|
||||
- **Tagging Module Refactoring**: Large-scale refactor to improve code quality and maintainability
|
||||
- Centralized regex patterns, extracted reusable utilities, decomposed complex functions
|
||||
- Improved code organization and readability while maintaining 100% tagging accuracy
|
||||
|
||||
### Added
|
||||
- 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
|
||||
- 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 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
|
||||
- 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)
|
||||
- Protection tag renamed to "Protective Effects" throughout web interface to avoid confusion with the Magic keyword "protection"
|
||||
- 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)
|
||||
- Tagging module imports standardized with consistent organization and centralized constants
|
||||
|
||||
### Fixed
|
||||
### 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
|
||||
- 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
|
||||
- Cards with static keywords (Protection, Hexproof, Ward, Indestructible) in their keywords field now get proper scope metadata tags
|
||||
- Cards with X in their mana cost now properly identified and tagged with "X Spells" theme for better deck building accuracy
|
||||
- Card tagging system enhanced with smarter pattern detection and more consistent categorization
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
=\ 1\; & \c:/Users/Matt/mtg_python/mtg_python_deckbuilder/.venv/Scripts/python.exe\ code/scripts/build_theme_catalog.py --output config/themes/theme_list_tmp.json
|
||||
|
|
@ -438,7 +438,7 @@ DEFAULT_REMOVAL_COUNT: Final[int] = 10 # Default number of spot removal spells
|
|||
DEFAULT_WIPES_COUNT: Final[int] = 2 # Default number of board wipes
|
||||
|
||||
DEFAULT_CARD_ADVANTAGE_COUNT: Final[int] = 10 # Default number of card advantage pieces
|
||||
DEFAULT_PROTECTION_COUNT: Final[int] = 8 # Default number of protection spells
|
||||
DEFAULT_PROTECTION_COUNT: Final[int] = 8 # Default number of protective effects (hexproof, indestructible, protection, ward, etc.)
|
||||
|
||||
# Deck composition prompts
|
||||
DECK_COMPOSITION_PROMPTS: Final[Dict[str, str]] = {
|
||||
|
|
@ -450,7 +450,7 @@ DECK_COMPOSITION_PROMPTS: Final[Dict[str, str]] = {
|
|||
'removal': 'Enter desired number of spot removal spells (default: 10):',
|
||||
'wipes': 'Enter desired number of board wipes (default: 2):',
|
||||
'card_advantage': 'Enter desired number of card advantage pieces (default: 10):',
|
||||
'protection': 'Enter desired number of protection spells (default: 8):',
|
||||
'protection': 'Enter desired number of protective effects (default: 8):',
|
||||
'max_deck_price': 'Enter maximum total deck price in dollars (default: 400.0):',
|
||||
'max_card_price': 'Enter maximum price per card in dollars (default: 20.0):'
|
||||
}
|
||||
|
|
@ -511,7 +511,7 @@ DEFAULT_THEME_TAGS = [
|
|||
'Combat Matters', 'Control', 'Counters Matter', 'Energy',
|
||||
'Enter the Battlefield', 'Equipment', 'Exile Matters', 'Infect',
|
||||
'Interaction', 'Lands Matter', 'Leave the Battlefield', 'Legends Matter',
|
||||
'Life Matters', 'Mill', 'Monarch', 'Protection', 'Ramp', 'Reanimate',
|
||||
'Life Matters', 'Mill', 'Monarch', 'Protective Effects', 'Ramp', 'Reanimate',
|
||||
'Removal', 'Sacrifice Matters', 'Spellslinger', 'Stax', 'Superfriends',
|
||||
'Theft', 'Token Creation', 'Tokens Matter', 'Voltron', 'X Spells'
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
# Standard library imports
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Set
|
||||
|
||||
# Third-party imports
|
||||
import pandas as pd
|
||||
|
||||
def _ensure_norm_series(df: pd.DataFrame, source_col: str, norm_col: str) -> pd.Series:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
# Standard library imports
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import json
|
||||
# Third-party imports
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
# Standard library imports
|
||||
import ast
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set, DefaultDict
|
||||
from collections import defaultdict
|
||||
from typing import DefaultDict, Dict, List, Set
|
||||
|
||||
# Third-party imports
|
||||
import pandas as pd
|
||||
|
||||
# Local application imports
|
||||
from settings import CSV_DIRECTORY, SETUP_COLORS
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,132 @@ def load_merge_summary() -> Dict[str, Any]:
|
|||
return {"updated_at": None, "colors": {}}
|
||||
|
||||
|
||||
def _merge_tag_columns(work_df: pd.DataFrame, group_sorted: pd.DataFrame, primary_idx: int) -> None:
|
||||
"""Merge list columns (themeTags, roleTags) into union values.
|
||||
|
||||
Args:
|
||||
work_df: Working DataFrame to update
|
||||
group_sorted: Sorted group of faces for a multi-face card
|
||||
primary_idx: Index of primary face to update
|
||||
"""
|
||||
for column in _LIST_UNION_COLUMNS:
|
||||
if column in group_sorted.columns:
|
||||
union_values = _merge_object_lists(group_sorted[column])
|
||||
work_df.at[primary_idx, column] = union_values
|
||||
|
||||
if "keywords" in group_sorted.columns:
|
||||
keyword_union = _merge_keywords(group_sorted["keywords"])
|
||||
work_df.at[primary_idx, "keywords"] = _join_keywords(keyword_union)
|
||||
|
||||
|
||||
def _build_face_payload(face_row: pd.Series) -> Dict[str, Any]:
|
||||
"""Build face metadata payload from a single face row.
|
||||
|
||||
Args:
|
||||
face_row: Single face row from grouped DataFrame
|
||||
|
||||
Returns:
|
||||
Dictionary containing face metadata
|
||||
"""
|
||||
text_val = face_row.get("text") or face_row.get("oracleText") or ""
|
||||
mana_cost_val = face_row.get("manaCost", face_row.get("mana_cost", "")) or ""
|
||||
mana_value_raw = face_row.get("manaValue", face_row.get("mana_value", ""))
|
||||
|
||||
try:
|
||||
if mana_value_raw in (None, ""):
|
||||
mana_value_val = None
|
||||
else:
|
||||
mana_value_val = float(mana_value_raw)
|
||||
if math.isnan(mana_value_val):
|
||||
mana_value_val = None
|
||||
except Exception:
|
||||
mana_value_val = None
|
||||
|
||||
type_val = face_row.get("type", "") or ""
|
||||
|
||||
return {
|
||||
"face": str(face_row.get("faceName") or face_row.get("name") or ""),
|
||||
"side": str(face_row.get("side") or ""),
|
||||
"layout": str(face_row.get("layout") or ""),
|
||||
"themeTags": _merge_object_lists([face_row.get("themeTags", [])]),
|
||||
"roleTags": _merge_object_lists([face_row.get("roleTags", [])]),
|
||||
"type": str(type_val),
|
||||
"text": str(text_val),
|
||||
"mana_cost": str(mana_cost_val),
|
||||
"mana_value": mana_value_val,
|
||||
"produces_mana": _text_produces_mana(text_val),
|
||||
"is_land": 'land' in str(type_val).lower(),
|
||||
}
|
||||
|
||||
|
||||
def _build_merge_detail(name: str, group_sorted: pd.DataFrame, faces_payload: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Build detailed merge information for a multi-face card group.
|
||||
|
||||
Args:
|
||||
name: Card name
|
||||
group_sorted: Sorted group of faces
|
||||
faces_payload: List of face metadata dictionaries
|
||||
|
||||
Returns:
|
||||
Dictionary containing merge details
|
||||
"""
|
||||
layout_set = sorted({f.get("layout", "") for f in faces_payload if f.get("layout")})
|
||||
removed_faces = faces_payload[1:] if len(faces_payload) > 1 else []
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"total_faces": len(group_sorted),
|
||||
"dropped_faces": max(len(group_sorted) - 1, 0),
|
||||
"layouts": layout_set,
|
||||
"primary_face": faces_payload[0] if faces_payload else {},
|
||||
"removed_faces": removed_faces,
|
||||
"theme_tags": sorted({tag for face in faces_payload for tag in face.get("themeTags", [])}),
|
||||
"role_tags": sorted({tag for face in faces_payload for tag in face.get("roleTags", [])}),
|
||||
"faces": faces_payload,
|
||||
}
|
||||
|
||||
|
||||
def _log_merge_summary(color: str, merged_count: int, drop_count: int, multi_face_count: int, logger) -> None:
|
||||
"""Log merge summary with structured and human-readable formats.
|
||||
|
||||
Args:
|
||||
color: Color being processed
|
||||
merged_count: Number of card groups merged
|
||||
drop_count: Number of face rows dropped
|
||||
multi_face_count: Total multi-face rows processed
|
||||
logger: Logger instance
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"dfc_merge_summary %s",
|
||||
json.dumps(
|
||||
{
|
||||
"event": "dfc_merge_summary",
|
||||
"color": color,
|
||||
"groups_merged": merged_count,
|
||||
"faces_dropped": drop_count,
|
||||
"multi_face_rows": multi_face_count,
|
||||
},
|
||||
sort_keys=True,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logger.info(
|
||||
"dfc_merge_summary event=%s groups=%d dropped=%d rows=%d",
|
||||
color,
|
||||
merged_count,
|
||||
drop_count,
|
||||
multi_face_count,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Merged %d multi-face card groups for %s (dropped %d extra faces)",
|
||||
merged_count,
|
||||
color,
|
||||
drop_count,
|
||||
)
|
||||
|
||||
|
||||
def merge_multi_face_rows(
|
||||
df: pd.DataFrame,
|
||||
color: str,
|
||||
|
|
@ -93,7 +219,6 @@ def merge_multi_face_rows(
|
|||
return df
|
||||
|
||||
work_df = df.copy()
|
||||
|
||||
layout_series = work_df["layout"].fillna("").astype(str).str.lower()
|
||||
multi_mask = layout_series.isin(_MULTI_FACE_LAYOUTS)
|
||||
|
||||
|
|
@ -110,66 +235,15 @@ def merge_multi_face_rows(
|
|||
|
||||
group_sorted = _sort_faces(group)
|
||||
primary_idx = group_sorted.index[0]
|
||||
faces_payload: List[Dict[str, Any]] = []
|
||||
|
||||
for column in _LIST_UNION_COLUMNS:
|
||||
if column in group_sorted.columns:
|
||||
union_values = _merge_object_lists(group_sorted[column])
|
||||
work_df.at[primary_idx, column] = union_values
|
||||
_merge_tag_columns(work_df, group_sorted, primary_idx)
|
||||
|
||||
if "keywords" in group_sorted.columns:
|
||||
keyword_union = _merge_keywords(group_sorted["keywords"])
|
||||
work_df.at[primary_idx, "keywords"] = _join_keywords(keyword_union)
|
||||
faces_payload = [_build_face_payload(row) for _, row in group_sorted.iterrows()]
|
||||
|
||||
for _, face_row in group_sorted.iterrows():
|
||||
text_val = face_row.get("text") or face_row.get("oracleText") or ""
|
||||
mana_cost_val = face_row.get("manaCost", face_row.get("mana_cost", "")) or ""
|
||||
mana_value_raw = face_row.get("manaValue", face_row.get("mana_value", ""))
|
||||
try:
|
||||
if mana_value_raw in (None, ""):
|
||||
mana_value_val = None
|
||||
else:
|
||||
mana_value_val = float(mana_value_raw)
|
||||
if math.isnan(mana_value_val):
|
||||
mana_value_val = None
|
||||
except Exception:
|
||||
mana_value_val = None
|
||||
type_val = face_row.get("type", "") or ""
|
||||
faces_payload.append(
|
||||
{
|
||||
"face": str(face_row.get("faceName") or face_row.get("name") or ""),
|
||||
"side": str(face_row.get("side") or ""),
|
||||
"layout": str(face_row.get("layout") or ""),
|
||||
"themeTags": _merge_object_lists([face_row.get("themeTags", [])]),
|
||||
"roleTags": _merge_object_lists([face_row.get("roleTags", [])]),
|
||||
"type": str(type_val),
|
||||
"text": str(text_val),
|
||||
"mana_cost": str(mana_cost_val),
|
||||
"mana_value": mana_value_val,
|
||||
"produces_mana": _text_produces_mana(text_val),
|
||||
"is_land": 'land' in str(type_val).lower(),
|
||||
}
|
||||
)
|
||||
|
||||
for idx in group_sorted.index[1:]:
|
||||
drop_indices.append(idx)
|
||||
drop_indices.extend(group_sorted.index[1:])
|
||||
|
||||
merged_count += 1
|
||||
layout_set = sorted({f.get("layout", "") for f in faces_payload if f.get("layout")})
|
||||
removed_faces = faces_payload[1:] if len(faces_payload) > 1 else []
|
||||
merge_details.append(
|
||||
{
|
||||
"name": name,
|
||||
"total_faces": len(group_sorted),
|
||||
"dropped_faces": max(len(group_sorted) - 1, 0),
|
||||
"layouts": layout_set,
|
||||
"primary_face": faces_payload[0] if faces_payload else {},
|
||||
"removed_faces": removed_faces,
|
||||
"theme_tags": sorted({tag for face in faces_payload for tag in face.get("themeTags", [])}),
|
||||
"role_tags": sorted({tag for face in faces_payload for tag in face.get("roleTags", [])}),
|
||||
"faces": faces_payload,
|
||||
}
|
||||
)
|
||||
merge_details.append(_build_merge_detail(name, group_sorted, faces_payload))
|
||||
|
||||
if drop_indices:
|
||||
work_df = work_df.drop(index=drop_indices)
|
||||
|
|
@ -192,38 +266,10 @@ def merge_multi_face_rows(
|
|||
logger.warning("Failed to record DFC merge summary for %s: %s", color, exc)
|
||||
|
||||
if logger is not None:
|
||||
try:
|
||||
logger.info(
|
||||
"dfc_merge_summary %s",
|
||||
json.dumps(
|
||||
{
|
||||
"event": "dfc_merge_summary",
|
||||
"color": color,
|
||||
"groups_merged": merged_count,
|
||||
"faces_dropped": len(drop_indices),
|
||||
"multi_face_rows": int(multi_mask.sum()),
|
||||
},
|
||||
sort_keys=True,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logger.info(
|
||||
"dfc_merge_summary event=%s groups=%d dropped=%d rows=%d",
|
||||
color,
|
||||
merged_count,
|
||||
len(drop_indices),
|
||||
int(multi_mask.sum()),
|
||||
)
|
||||
logger.info(
|
||||
"Merged %d multi-face card groups for %s (dropped %d extra faces)",
|
||||
merged_count,
|
||||
color,
|
||||
len(drop_indices),
|
||||
)
|
||||
_log_merge_summary(color, merged_count, len(drop_indices), int(multi_mask.sum()), logger)
|
||||
|
||||
_persist_merge_summary(color, summary_payload, logger)
|
||||
|
||||
# Reset index to keep downstream expectations consistent.
|
||||
return work_df.reset_index(drop=True)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,15 +9,97 @@ Detects the scope of phasing effects with multiple dimensions:
|
|||
- Blanket: Phasing (phases all permanents out)
|
||||
|
||||
Cards can have multiple scope tags (e.g., Targeted + Your Permanents).
|
||||
|
||||
Refactored in M2: Create Scope Detection Utilities to use generic scope detection.
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import re
|
||||
from typing import Set
|
||||
|
||||
# Local application imports
|
||||
from . import scope_detection_utils as scope_utils
|
||||
from code.logging_util import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Phasing scope pattern definitions
|
||||
def _get_phasing_scope_patterns() -> scope_utils.ScopePatterns:
|
||||
"""
|
||||
Build scope patterns for phasing abilities.
|
||||
|
||||
Returns:
|
||||
ScopePatterns object with compiled patterns
|
||||
"""
|
||||
# Targeting patterns (special for phasing - detects "target...phases out")
|
||||
targeting_patterns = [
|
||||
re.compile(r'target\s+(?:\w+\s+)*(?:creature|permanent|artifact|enchantment|nonland\s+permanent)s?(?:[^.]*)?phases?\s+out', re.IGNORECASE),
|
||||
re.compile(r'target\s+player\s+controls[^.]*phases?\s+out', re.IGNORECASE),
|
||||
]
|
||||
|
||||
# Self-reference patterns
|
||||
self_patterns = [
|
||||
re.compile(r'this\s+(?:creature|permanent|artifact|enchantment)\s+phases?\s+out', re.IGNORECASE),
|
||||
re.compile(r'~\s+phases?\s+out', re.IGNORECASE),
|
||||
# Triggered self-phasing (King of the Oathbreakers)
|
||||
re.compile(r'whenever.*(?:becomes\s+the\s+target|becomes\s+target).*(?:it|this\s+creature)\s+phases?\s+out', re.IGNORECASE),
|
||||
# Consequent self-phasing (Cyclonus: "connive. Then...phase out")
|
||||
re.compile(r'(?:then|,)\s+(?:it|this\s+creature)\s+phases?\s+out', re.IGNORECASE),
|
||||
# At end of turn/combat self-phasing
|
||||
re.compile(r'(?:at\s+(?:the\s+)?end\s+of|after).*(?:it|this\s+creature)\s+phases?\s+out', re.IGNORECASE),
|
||||
]
|
||||
|
||||
# Opponent patterns
|
||||
opponent_patterns = [
|
||||
re.compile(r'target\s+(?:\w+\s+)*(?:creature|permanent)\s+an?\s+opponents?\s+controls?\s+phases?\s+out', re.IGNORECASE),
|
||||
# Unqualified targets (can target opponents' stuff if no "you control" restriction)
|
||||
re.compile(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', re.IGNORECASE),
|
||||
re.compile(r'target\s+(?:\w+\s+)*(?:creature|permanent|artifact|enchantment|land|nonland\s+permanent)(?:,|\s+and)?\s+(?:then|and)?\s+it\s+phases?\s+out', re.IGNORECASE),
|
||||
]
|
||||
|
||||
# Your permanents patterns
|
||||
your_patterns = [
|
||||
# Explicit "you control"
|
||||
re.compile(r'(?:target\s+)?(?:creatures?|permanents?|nonland\s+permanents?)\s+you\s+control\s+phases?\s+out', re.IGNORECASE),
|
||||
re.compile(r'(?:target\s+)?(?:other\s+)?(?:creatures?|permanents?)\s+you\s+control\s+phases?\s+out', re.IGNORECASE),
|
||||
re.compile(r'permanents?\s+you\s+control\s+phase\s+out', re.IGNORECASE),
|
||||
re.compile(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', re.IGNORECASE),
|
||||
re.compile(r'all\s+(?:creatures?|permanents?)\s+you\s+control\s+phase\s+out', re.IGNORECASE),
|
||||
re.compile(r'each\s+(?:creature|permanent)\s+you\s+control\s+phases?\s+out', re.IGNORECASE),
|
||||
# Pronoun reference to "you control" context
|
||||
re.compile(r'(?:creatures?|permanents?|planeswalkers?)\s+you\s+control[^.]*(?:those|the)\s+(?:creatures?|permanents?|planeswalkers?)\s+phase\s+out', re.IGNORECASE),
|
||||
re.compile(r'creature\s+you\s+control[^.]*(?:it)\s+phases?\s+out', re.IGNORECASE),
|
||||
re.compile(r'you\s+control.*those\s+(?:creatures?|permanents?|planeswalkers?)\s+phase\s+out', re.IGNORECASE),
|
||||
# Equipment/Aura
|
||||
re.compile(r'equipped\s+(?:creature|permanent)\s+(?:gets\s+[^.]*\s+and\s+)?phases?\s+out', re.IGNORECASE),
|
||||
re.compile(r'enchanted\s+(?:creature|permanent)\s+(?:gets\s+[^.]*\s+and\s+)?phases?\s+out', re.IGNORECASE),
|
||||
re.compile(r'enchanted\s+(?:creature|permanent)\s+(?:has|gains?)\s+phasing', re.IGNORECASE),
|
||||
re.compile(r'(?:equipped|enchanted)\s+(?:creature|permanent)[^.]*,?\s+(?:then\s+)?that\s+(?:creature|permanent)\s+phases?\s+out', re.IGNORECASE),
|
||||
# Target controlled by specific player
|
||||
re.compile(r'(?:each|target)\s+(?:creature|permanent)\s+target\s+player\s+controls\s+phases?\s+out', re.IGNORECASE),
|
||||
]
|
||||
|
||||
# Blanket patterns
|
||||
blanket_patterns = [
|
||||
re.compile(r'all\s+(?:nontoken\s+)?(?:creatures?|permanents?)(?:\s+of\s+that\s+type)?\s+(?:[^.]*\s+)?phase\s+out', re.IGNORECASE),
|
||||
re.compile(r'each\s+(?:creature|permanent)\s+(?:[^.]*\s+)?phases?\s+out', re.IGNORECASE),
|
||||
# Type-specific blanket (Shimmer)
|
||||
re.compile(r'each\s+(?:land|creature|permanent|artifact|enchantment)\s+of\s+the\s+chosen\s+type\s+has\s+phasing', re.IGNORECASE),
|
||||
re.compile(r'(?:lands?|creatures?|permanents?|artifacts?|enchantments?)\s+of\s+the\s+chosen\s+type\s+(?:have|has)\s+phasing', re.IGNORECASE),
|
||||
# Pronoun reference to "all creatures"
|
||||
re.compile(r'all\s+(?:nontoken\s+)?(?:creatures?|permanents?)[^.]*,?\s+(?:then\s+)?(?:those|the)\s+(?:creatures?|permanents?)\s+phase\s+out', re.IGNORECASE),
|
||||
]
|
||||
|
||||
return scope_utils.ScopePatterns(
|
||||
opponent=opponent_patterns,
|
||||
self_ref=self_patterns,
|
||||
your_permanents=your_patterns,
|
||||
blanket=blanket_patterns,
|
||||
targeted=targeting_patterns
|
||||
)
|
||||
|
||||
|
||||
def get_phasing_scope_tags(text: str, card_name: str, keywords: str = '') -> Set[str]:
|
||||
"""
|
||||
Get all phasing scope metadata tags for a card.
|
||||
|
|
@ -47,121 +129,46 @@ def get_phasing_scope_tags(text: str, card_name: str, keywords: str = '') -> 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(
|
||||
# Define patterns for checking if card grants phasing to others
|
||||
grants_pattern = [re.compile(
|
||||
r'(other|target|each|all|enchanted|equipped|creatures? you control|permanents? you control).*phas',
|
||||
text_no_reminder
|
||||
))
|
||||
re.IGNORECASE
|
||||
)]
|
||||
|
||||
# If no granting language, it's just self-phasing
|
||||
if not grants_to_others:
|
||||
is_static = scope_utils.check_static_keyword_legacy(
|
||||
keywords=keywords,
|
||||
static_keyword='phasing',
|
||||
text=text,
|
||||
grant_patterns=grants_pattern
|
||||
)
|
||||
|
||||
if is_static:
|
||||
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
|
||||
# Check if phasing is mentioned in text
|
||||
if 'phas' not in text_lower:
|
||||
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',
|
||||
]
|
||||
# Build phasing patterns and detect scopes
|
||||
patterns = _get_phasing_scope_patterns()
|
||||
|
||||
is_targeted = any(re.search(pattern, text_lower) for pattern in targeting_patterns)
|
||||
# Detect all scopes (phasing can have multiple)
|
||||
scopes = scope_utils.detect_multi_scope(
|
||||
text=text,
|
||||
card_name=card_name,
|
||||
ability_keyword='phas', # Use 'phas' to catch both 'phase' and 'phasing'
|
||||
patterns=patterns,
|
||||
check_grant_verbs=False # Phasing doesn't need grant verb checking
|
||||
)
|
||||
|
||||
if is_targeted:
|
||||
# Format scope tags with "Phasing" ability name
|
||||
for scope in scopes:
|
||||
if scope == "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")
|
||||
else:
|
||||
tags.add(scope_utils.format_scope_tag(scope, "Phasing"))
|
||||
logger.debug(f"Card '{card_name}': detected {scope}: Phasing")
|
||||
|
||||
return tags
|
||||
|
||||
|
|
|
|||
|
|
@ -10,126 +10,135 @@ Usage in tagger.py:
|
|||
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
|
||||
from typing import List, Pattern, Set
|
||||
from . import regex_patterns as rgx
|
||||
from . import tag_utils
|
||||
from .tag_constants import CONTEXT_WINDOW_SIZE, CREATURE_TYPES, PROTECTION_KEYWORDS
|
||||
|
||||
|
||||
# Pre-compile kindred detection patterns at module load for performance
|
||||
# Pattern: (compiled_regex, tag_name_template)
|
||||
KINDRED_PATTERNS: List[tuple[Pattern, str]] = []
|
||||
def _build_kindred_patterns() -> List[tuple[Pattern, str]]:
|
||||
"""Build pre-compiled kindred patterns for all creature types.
|
||||
|
||||
def _init_kindred_patterns():
|
||||
"""Initialize pre-compiled kindred patterns for all creature types."""
|
||||
global KINDRED_PATTERNS
|
||||
if KINDRED_PATTERNS:
|
||||
return # Already initialized
|
||||
Returns:
|
||||
List of tuples containing (compiled_pattern, tag_name)
|
||||
"""
|
||||
patterns = []
|
||||
|
||||
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),
|
||||
pattern_templates = [
|
||||
rf'\bother {creature_escaped}s?\b.*\b(have|gain)\b',
|
||||
rf'\b{creature_escaped} creatures?\b.*\b(have|gain)\b',
|
||||
rf'\btarget {creature_escaped}\b.*\bgains?\b',
|
||||
]
|
||||
|
||||
for pattern_str, tag in patterns_to_compile:
|
||||
for pattern_str in pattern_templates:
|
||||
try:
|
||||
compiled = re.compile(pattern_str, re.IGNORECASE)
|
||||
KINDRED_PATTERNS.append((compiled, tag))
|
||||
patterns.append((compiled, tag_name))
|
||||
except re.error:
|
||||
# Skip patterns that fail to compile
|
||||
pass
|
||||
|
||||
return patterns
|
||||
KINDRED_PATTERNS: List[tuple[Pattern, str]] = _build_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|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")
|
||||
# Pre-compiled at module load for performance
|
||||
GRANT_VERB_PATTERNS: List[Pattern] = [
|
||||
re.compile(r'\bgain[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection|phasing)\b', re.IGNORECASE),
|
||||
re.compile(r'\bgive[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection|phasing)\b', re.IGNORECASE),
|
||||
re.compile(r'\bgrant[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection|phasing)\b', re.IGNORECASE),
|
||||
re.compile(r'\bhave\b.*\b(hexproof|shroud|indestructible|ward|protection|phasing)\b', re.IGNORECASE), # "have hexproof" static grants
|
||||
re.compile(r'\bget[s]?\b.*\+.*\b(hexproof|shroud|indestructible|ward|protection|phasing)\b', re.IGNORECASE), # "gets +X/+X and has hexproof" direct
|
||||
re.compile(r'\bget[s]?\b.*\+.*\band\b.*\b(gain[s]?|have)\b.*\b(hexproof|shroud|indestructible|ward|protection|phasing)\b', re.IGNORECASE), # "gets +X/+X and gains hexproof"
|
||||
re.compile(r'\bphases? out\b', re.IGNORECASE), # 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|phasing)', # Start of text (keyword ability)
|
||||
r'\([^)]*\b(hexproof|shroud|indestructible|ward|protection|phasing)[^)]*\)', # Reminder text in parens
|
||||
# Pre-compiled at module load for performance
|
||||
SELF_REFERENCE_PATTERNS: List[Pattern] = [
|
||||
re.compile(r'^\s*(hexproof|shroud|indestructible|ward|protection|phasing)', re.IGNORECASE), # Start of text (keyword ability)
|
||||
re.compile(r'\([^)]*\b(hexproof|shroud|indestructible|ward|protection|phasing)[^)]*\)', re.IGNORECASE), # Reminder text in parens
|
||||
]
|
||||
|
||||
# Conditional self-grant patterns - activated/triggered abilities that grant to self
|
||||
CONDITIONAL_SELF_GRANT_PATTERNS = [
|
||||
# Pre-compiled at module load for performance
|
||||
CONDITIONAL_SELF_GRANT_PATTERNS: List[Pattern] = [
|
||||
# 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',
|
||||
re.compile(r'\{[^}]*\}.*:.*\bthis (creature|permanent|artifact|enchantment)\b.*\bgain[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection)\b', re.IGNORECASE),
|
||||
re.compile(r'discard.*:.*\bthis (creature|permanent|artifact|enchantment)\b.*\bgain[s]?\b', re.IGNORECASE),
|
||||
re.compile(r'\{t\}.*:.*\bthis (creature|permanent|artifact|enchantment)\b.*\bgain[s]?\b', re.IGNORECASE),
|
||||
re.compile(r'sacrifice.*:.*\bthis (creature|permanent|artifact|enchantment)\b.*\bgain[s]?\b', re.IGNORECASE),
|
||||
re.compile(r'pay.*life.*:.*\bthis (creature|permanent|artifact|enchantment)\b.*\bgain[s]?\b', re.IGNORECASE),
|
||||
# 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',
|
||||
re.compile(r'whenever.*\b(this creature|this permanent|it)\b.*\bgain[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection)\b', re.IGNORECASE),
|
||||
re.compile(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', re.IGNORECASE),
|
||||
re.compile(r'at the beginning.*\b(this creature|this permanent|it)\b.*\bgain[s]?\b.*\b(hexproof|shroud|indestructible|ward|protection)\b', re.IGNORECASE),
|
||||
re.compile(r'whenever.*\b(this creature|this permanent)\b (attacks|enters|becomes).*\b(this creature|this permanent|it)\b.*\bgain[s]?\b', re.IGNORECASE),
|
||||
# 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',
|
||||
re.compile(r'whenever you cast.*[A-Z][a-z]+.*gains.*\b(hexproof|shroud|indestructible|ward|protection)\b', re.IGNORECASE),
|
||||
re.compile(r'whenever you.*[A-Z][a-z]+.*gains.*\b(hexproof|shroud|indestructible|ward|protection)\b', re.IGNORECASE),
|
||||
# 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',
|
||||
re.compile(r'as long as.*\b(this creature|this permanent|it|has)\b.*(has|gains?).*\b(hexproof|shroud|indestructible|ward|protection)\b', re.IGNORECASE),
|
||||
]
|
||||
|
||||
# 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...
|
||||
# Pre-compiled at module load for performance
|
||||
MASS_GRANT_PATTERNS: List[Pattern] = [
|
||||
re.compile(r'creatures you control (have|gain|get)', re.IGNORECASE),
|
||||
re.compile(r'other .* you control (have|gain|get)', re.IGNORECASE),
|
||||
re.compile(r'(artifacts?|enchantments?|permanents?) you control (have|gain|get)', re.IGNORECASE), # Artifacts you control have...
|
||||
re.compile(r'other (creatures?|artifacts?|enchantments?) (have|gain|get)', re.IGNORECASE), # Other creatures have...
|
||||
re.compile(r'all (creatures?|slivers?|permanents?) (have|gain|get)', re.IGNORECASE), # 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)',
|
||||
# Pre-compiled at module load for performance
|
||||
TARGETED_GRANT_PATTERNS: List[Pattern] = [
|
||||
re.compile(r'target .* you control (gains?|gets?|has)', re.IGNORECASE),
|
||||
re.compile(r'equipped creature (gains?|gets?|has)', re.IGNORECASE),
|
||||
re.compile(r'enchanted enchantment (gains?|gets?|has)', re.IGNORECASE),
|
||||
]
|
||||
|
||||
# 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",
|
||||
# Pre-compiled at module load for performance
|
||||
EXCLUSION_PATTERNS: List[Pattern] = [
|
||||
re.compile(r"can't have (hexproof|indestructible|ward|shroud)", re.IGNORECASE),
|
||||
re.compile(r"lose[s]? (hexproof|indestructible|ward|shroud|protection)", re.IGNORECASE),
|
||||
re.compile(r"without (hexproof|indestructible|ward|shroud)", re.IGNORECASE),
|
||||
re.compile(r"protection from.*can't", re.IGNORECASE),
|
||||
]
|
||||
|
||||
# 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'opponents? control', # creatures your opponents control
|
||||
r'opponent.*permanents?.*have', # opponent's permanents have
|
||||
# Pre-compiled at module load for performance
|
||||
OPPONENT_GRANT_PATTERNS: List[Pattern] = [
|
||||
rgx.TARGET_OPPONENT,
|
||||
rgx.EACH_OPPONENT,
|
||||
rgx.OPPONENT_CONTROL,
|
||||
re.compile(r'opponent.*permanents?.*have', re.IGNORECASE), # 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)
|
||||
# Pre-compiled at module load for performance
|
||||
BLANKET_GRANT_PATTERNS: List[Pattern] = [
|
||||
re.compile(r'\ball creatures? (have|gain|get)\b', re.IGNORECASE), # All creatures gain hexproof
|
||||
re.compile(r'\ball permanents? (have|gain|get)\b', re.IGNORECASE), # All permanents gain indestructible
|
||||
re.compile(r'\beach creature (has|gains?|gets?)\b', re.IGNORECASE), # Each creature gains ward
|
||||
rgx.EACH_PLAYER, # Each player gains hexproof (very rare but valid blanket)
|
||||
]
|
||||
|
||||
# Kindred-specific grant patterns for metadata tagging
|
||||
|
|
@ -178,16 +187,6 @@ KINDRED_GRANT_PATTERNS = {
|
|||
],
|
||||
}
|
||||
|
||||
# Protection keyword patterns for inherent check
|
||||
PROTECTION_KEYWORDS = {
|
||||
'hexproof',
|
||||
'shroud',
|
||||
'indestructible',
|
||||
'ward',
|
||||
'protection from',
|
||||
'protection',
|
||||
}
|
||||
|
||||
|
||||
def get_kindred_protection_tags(text: str) -> Set[str]:
|
||||
"""
|
||||
|
|
@ -207,9 +206,6 @@ def get_kindred_protection_tags(text: str) -> Set[str]:
|
|||
if not text:
|
||||
return set()
|
||||
|
||||
# Initialize pre-compiled patterns if needed
|
||||
_init_kindred_patterns()
|
||||
|
||||
text_lower = text.lower()
|
||||
tags = set()
|
||||
|
||||
|
|
@ -217,13 +213,11 @@ def get_kindred_protection_tags(text: str) -> Set[str]:
|
|||
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)
|
||||
pattern_compiled = re.compile(pattern, re.IGNORECASE) if isinstance(pattern, str) else pattern
|
||||
match = pattern_compiled.search(text_lower)
|
||||
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)
|
||||
|
|
@ -244,7 +238,6 @@ def get_kindred_protection_tags(text: str) -> Set[str]:
|
|||
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)
|
||||
|
|
@ -278,18 +271,16 @@ def is_opponent_grant(text: str) -> bool:
|
|||
|
||||
# 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)
|
||||
text_no_reminder = tag_utils.strip_reminder_text(text_lower)
|
||||
for pattern in OPPONENT_GRANT_PATTERNS:
|
||||
match = re.search(pattern, text_no_reminder, re.IGNORECASE)
|
||||
match = pattern.search(text_no_reminder)
|
||||
if match:
|
||||
# Must be in context of granting protection
|
||||
if any(prot in text_lower for prot in ['hexproof', 'shroud', 'indestructible', 'ward', 'protection']):
|
||||
# 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]
|
||||
context = tag_utils.extract_context_window(
|
||||
text_no_reminder, match.start(), match.end(),
|
||||
window_size=CONTEXT_WINDOW_SIZE, include_before=True
|
||||
)
|
||||
|
||||
# If "you control" appears in the context, it's limiting to YOUR permanents, not opponents
|
||||
if 'you control' not in context:
|
||||
|
|
@ -307,10 +298,8 @@ def has_conditional_self_grant(text: str) -> bool:
|
|||
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):
|
||||
if pattern.search(text_lower):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
@ -331,30 +320,121 @@ def is_conditional_self_grant(text: str) -> bool:
|
|||
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',
|
||||
])
|
||||
other_grant_patterns = [
|
||||
rgx.OTHER_CREATURES,
|
||||
re.compile(r'creatures you control (have|gain)', re.IGNORECASE),
|
||||
re.compile(r'target (creature|permanent) you control gains', re.IGNORECASE),
|
||||
re.compile(r'another target (creature|permanent)', re.IGNORECASE),
|
||||
re.compile(r'equipped creature (has|gains)', re.IGNORECASE),
|
||||
re.compile(r'enchanted creature (has|gains)', re.IGNORECASE),
|
||||
re.compile(r'target legendary', re.IGNORECASE),
|
||||
re.compile(r'permanents you control gain', re.IGNORECASE),
|
||||
]
|
||||
has_other_grant = any(pattern.search(text_lower) for pattern in other_grant_patterns)
|
||||
|
||||
# Return True only if it's ONLY conditional self-grants (no other grants)
|
||||
return not has_other_grant
|
||||
|
||||
|
||||
def _should_exclude_token_creation(text_lower: str) -> bool:
|
||||
"""Check if card only creates tokens with protection (not granting to existing permanents).
|
||||
|
||||
Args:
|
||||
text_lower: Lowercased card text
|
||||
|
||||
Returns:
|
||||
True if card only creates tokens, False if it also grants
|
||||
"""
|
||||
token_with_protection = re.compile(r'create.*token.*with.*(hexproof|shroud|indestructible|ward|protection)', re.IGNORECASE)
|
||||
if token_with_protection.search(text_lower):
|
||||
has_grant_to_others = any(pattern.search(text_lower) for pattern in MASS_GRANT_PATTERNS)
|
||||
return not has_grant_to_others
|
||||
return False
|
||||
|
||||
|
||||
def _should_exclude_kindred_only(text: str, text_lower: str, exclude_kindred: bool) -> bool:
|
||||
"""Check if card only grants to specific kindred types.
|
||||
|
||||
Args:
|
||||
text: Original card text
|
||||
text_lower: Lowercased card text
|
||||
exclude_kindred: Whether to exclude kindred-specific grants
|
||||
|
||||
Returns:
|
||||
True if card only has kindred grants, False if it has broad grants
|
||||
"""
|
||||
if not exclude_kindred:
|
||||
return False
|
||||
|
||||
kindred_tags = get_kindred_protection_tags(text)
|
||||
if not kindred_tags:
|
||||
return False
|
||||
broad_only_patterns = [
|
||||
re.compile(r'\bcreatures you control (have|gain)\b(?!.*(knight|merfolk|zombie|elf|dragon|goblin|sliver))', re.IGNORECASE),
|
||||
re.compile(r'\bpermanents you control (have|gain)\b', re.IGNORECASE),
|
||||
re.compile(r'\beach (creature|permanent) you control', re.IGNORECASE),
|
||||
re.compile(r'\ball (creatures?|permanents?)', re.IGNORECASE),
|
||||
]
|
||||
|
||||
has_broad_grant = any(pattern.search(text_lower) for pattern in broad_only_patterns)
|
||||
return not has_broad_grant
|
||||
|
||||
|
||||
def _check_pattern_grants(text_lower: str, pattern_list: List[Pattern]) -> bool:
|
||||
"""Check if text contains protection grants matching pattern list.
|
||||
|
||||
Args:
|
||||
text_lower: Lowercased card text
|
||||
pattern_list: List of grant patterns to check
|
||||
|
||||
Returns:
|
||||
True if protection grant found, False otherwise
|
||||
"""
|
||||
for pattern in pattern_list:
|
||||
match = pattern.search(text_lower)
|
||||
if match:
|
||||
context = tag_utils.extract_context_window(text_lower, match.start(), match.end())
|
||||
if any(prot in context for prot in PROTECTION_KEYWORDS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_inherent_protection_only(text_lower: str, keywords: str, found_grant: bool) -> bool:
|
||||
"""Check if card only has inherent protection without granting.
|
||||
|
||||
Args:
|
||||
text_lower: Lowercased card text
|
||||
keywords: Card keywords
|
||||
found_grant: Whether a grant pattern was found
|
||||
|
||||
Returns:
|
||||
True if card only has inherent protection, False otherwise
|
||||
"""
|
||||
if not keywords:
|
||||
return False
|
||||
|
||||
keywords_lower = keywords.lower()
|
||||
has_inherent = any(k in keywords_lower for k in PROTECTION_KEYWORDS)
|
||||
|
||||
if not has_inherent or found_grant:
|
||||
return False
|
||||
stat_only_pattern = re.compile(r'(get[s]?|gain[s]?)\s+[+\-][0-9X]+/[+\-][0-9X]+', re.IGNORECASE)
|
||||
has_stat_only = bool(stat_only_pattern.search(text_lower))
|
||||
mentions_other_without_prot = False
|
||||
if 'other' in text_lower:
|
||||
other_idx = text_lower.find('other')
|
||||
remaining_text = text_lower[other_idx:]
|
||||
mentions_other_without_prot = not any(prot in remaining_text for prot in PROTECTION_KEYWORDS)
|
||||
|
||||
return has_stat_only or mentions_other_without_prot
|
||||
|
||||
|
||||
def is_granting_protection(text: str, keywords: str, exclude_kindred: bool = False) -> bool:
|
||||
"""
|
||||
Determine if a card grants protection effects to other permanents.
|
||||
|
|
@ -381,116 +461,31 @@ def is_granting_protection(text: str, keywords: str, exclude_kindred: bool = Fal
|
|||
|
||||
text_lower = text.lower()
|
||||
|
||||
# EXCLUDE: Opponent grants
|
||||
# Early exclusion checks
|
||||
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):
|
||||
if any(pattern.search(text_lower) for pattern in EXCLUSION_PATTERNS):
|
||||
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:
|
||||
if _should_exclude_token_creation(text_lower):
|
||||
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
|
||||
if _should_exclude_kindred_only(text, text_lower, exclude_kindred):
|
||||
return False
|
||||
found_grant = False
|
||||
|
||||
# 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 nearby
|
||||
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):
|
||||
if _check_pattern_grants(text_lower, BLANKET_GRANT_PATTERNS):
|
||||
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):
|
||||
elif _check_pattern_grants(text_lower, MASS_GRANT_PATTERNS):
|
||||
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):
|
||||
elif _check_pattern_grants(text_lower, TARGETED_GRANT_PATTERNS):
|
||||
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):
|
||||
elif any(pattern.search(text_lower) for pattern in GRANT_VERB_PATTERNS):
|
||||
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:
|
||||
if _has_inherent_protection_only(text_lower, keywords, found_grant):
|
||||
return False
|
||||
|
||||
return found_grant
|
||||
|
|
@ -516,25 +511,14 @@ def categorize_protection_card(name: str, text: str, keywords: str, card_type: s
|
|||
'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:
|
||||
|
|
@ -551,8 +535,6 @@ def categorize_protection_card(name: str, text: str, keywords: str, card_type: s
|
|||
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
|
||||
|
|
|
|||
|
|
@ -5,39 +5,99 @@ Detects the scope of protection effects (Self, Your Permanents, Blanket, Opponen
|
|||
to enable intelligent filtering in deck building.
|
||||
|
||||
Part of M5: Protection Effect Granularity milestone.
|
||||
Refactored in M2: Create Scope Detection Utilities to use generic scope detection.
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import re
|
||||
from typing import Optional, Set
|
||||
|
||||
# Local application imports
|
||||
from code.logging_util import get_logger
|
||||
from . import scope_detection_utils as scope_utils
|
||||
from .tag_constants import PROTECTION_ABILITIES
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Protection abilities to detect
|
||||
PROTECTION_ABILITIES = [
|
||||
'Protection',
|
||||
'Ward',
|
||||
'Hexproof',
|
||||
'Shroud',
|
||||
'Indestructible'
|
||||
# Protection scope pattern definitions
|
||||
def _get_protection_scope_patterns(ability: str) -> scope_utils.ScopePatterns:
|
||||
"""
|
||||
Build scope patterns for protection abilities.
|
||||
|
||||
Args:
|
||||
ability: Ability keyword (e.g., "hexproof", "ward")
|
||||
|
||||
Returns:
|
||||
ScopePatterns object with compiled patterns
|
||||
"""
|
||||
ability_lower = ability.lower()
|
||||
|
||||
# Opponent patterns: grants protection TO opponent's permanents
|
||||
# Note: Must distinguish from hexproof reminder text "opponents control [spells/abilities]"
|
||||
opponent_patterns = [
|
||||
re.compile(r'creatures?\s+(?:your\s+)?opponents?\s+control\s+(?:have|gain)', re.IGNORECASE),
|
||||
re.compile(r'permanents?\s+(?:your\s+)?opponents?\s+control\s+(?:have|gain)', re.IGNORECASE),
|
||||
re.compile(r'each\s+creature\s+an?\s+opponent\s+controls?\s+(?:has|gains?)', re.IGNORECASE),
|
||||
]
|
||||
|
||||
# Self-reference patterns
|
||||
self_patterns = [
|
||||
# Tilde (~) - strong self-reference indicator
|
||||
re.compile(r'~\s+(?:has|gains?)\s+' + ability_lower, re.IGNORECASE),
|
||||
re.compile(r'~\s+is\s+' + ability_lower, re.IGNORECASE),
|
||||
# "this creature/permanent" pronouns
|
||||
re.compile(r'this\s+(?:creature|permanent|artifact|enchantment)\s+(?:has|gains?)\s+' + ability_lower, re.IGNORECASE),
|
||||
# Starts with ability (likely self)
|
||||
re.compile(r'^(?:has|gains?)\s+' + ability_lower, re.IGNORECASE),
|
||||
]
|
||||
|
||||
def detect_protection_scope(text: str, card_name: str, ability: str) -> Optional[str]:
|
||||
# Your permanents patterns
|
||||
your_patterns = [
|
||||
re.compile(r'(?:other\s+)?(?:creatures?|permanents?|artifacts?|enchantments?)\s+you\s+control', re.IGNORECASE),
|
||||
re.compile(r'your\s+(?:creatures?|permanents?|artifacts?|enchantments?)', re.IGNORECASE),
|
||||
re.compile(r'each\s+(?:creature|permanent)\s+you\s+control', re.IGNORECASE),
|
||||
re.compile(r'other\s+\w+s?\s+you\s+control', re.IGNORECASE), # "Other Merfolk you control", etc.
|
||||
# "Other X you control...have Y" pattern for static grants
|
||||
re.compile(r'other\s+(?:\w+\s+)?(?:creatures?|permanents?)\s+you\s+control\s+(?:get\s+[^.]*\s+and\s+)?have\s+' + ability_lower, re.IGNORECASE),
|
||||
re.compile(r'other\s+\w+s?\s+you\s+control\s+(?:get\s+[^.]*\s+and\s+)?have\s+' + ability_lower, re.IGNORECASE), # "Other Knights you control...have"
|
||||
re.compile(r'equipped\s+(?:creature|permanent)\s+(?:gets\s+[^.]*\s+and\s+)?(?:has|gains?)\s+(?:[^.]*\s+and\s+)?' + ability_lower, re.IGNORECASE), # Equipment
|
||||
re.compile(r'enchanted\s+(?:creature|permanent)\s+(?:gets\s+[^.]*\s+and\s+)?(?:has|gains?)\s+(?:[^.]*\s+and\s+)?' + ability_lower, re.IGNORECASE), # Aura
|
||||
re.compile(r'target\s+(?:\w+\s+)?(?:creature|permanent)\s+(?:gets\s+[^.]*\s+and\s+)?(?:gains?)\s+' + ability_lower, re.IGNORECASE), # Target
|
||||
]
|
||||
|
||||
# Blanket patterns (no ownership qualifier)
|
||||
# Note: Abilities can be listed with "and" (e.g., "gain hexproof and indestructible")
|
||||
blanket_patterns = [
|
||||
re.compile(r'all\s+(?:creatures?|permanents?)\s+(?:have|gain)\s+(?:[^.]*\s+and\s+)?' + ability_lower, re.IGNORECASE),
|
||||
re.compile(r'each\s+(?:creature|permanent)\s+(?:has|gains?)\s+(?:[^.]*\s+and\s+)?' + ability_lower, re.IGNORECASE),
|
||||
re.compile(r'(?:creatures?|permanents?)\s+(?:have|gain)\s+(?:[^.]*\s+and\s+)?' + ability_lower, re.IGNORECASE),
|
||||
]
|
||||
|
||||
return scope_utils.ScopePatterns(
|
||||
opponent=opponent_patterns,
|
||||
self_ref=self_patterns,
|
||||
your_permanents=your_patterns,
|
||||
blanket=blanket_patterns
|
||||
)
|
||||
|
||||
|
||||
def detect_protection_scope(text: str, card_name: str, ability: str, keywords: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Detect the scope of a protection effect.
|
||||
|
||||
Detection priority order (prevents misclassification):
|
||||
0. Static keyword → "Self"
|
||||
1. Opponent ownership → "Opponent Permanents"
|
||||
2. Your ownership → "Your Permanents"
|
||||
3. Self-reference → "Self"
|
||||
2. Self-reference → "Self"
|
||||
3. Your ownership → "Your Permanents"
|
||||
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.)
|
||||
keywords: Optional keywords field for static keyword detection
|
||||
|
||||
Returns:
|
||||
Scope prefix or None: "Self", "Your Permanents", "Blanket", "Opponent Permanents"
|
||||
|
|
@ -45,120 +105,22 @@ def detect_protection_scope(text: str, card_name: str, ability: str) -> Optional
|
|||
if not text or not ability:
|
||||
return None
|
||||
|
||||
text_lower = text.lower()
|
||||
ability_lower = ability.lower()
|
||||
card_name_lower = card_name.lower()
|
||||
# Build patterns for this ability
|
||||
patterns = _get_protection_scope_patterns(ability)
|
||||
|
||||
# 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
|
||||
# Use generic scope detection with grant verb checking AND keywords
|
||||
return scope_utils.detect_scope(
|
||||
text=text,
|
||||
card_name=card_name,
|
||||
ability_keyword=ability,
|
||||
patterns=patterns,
|
||||
allow_multiple=False,
|
||||
check_grant_verbs=True,
|
||||
keywords=keywords
|
||||
)
|
||||
|
||||
|
||||
def get_protection_scope_tags(text: str, card_name: str) -> Set[str]:
|
||||
def get_protection_scope_tags(text: str, card_name: str, keywords: Optional[str] = None) -> Set[str]:
|
||||
"""
|
||||
Get all protection scope metadata tags for a card.
|
||||
|
||||
|
|
@ -167,6 +129,7 @@ def get_protection_scope_tags(text: str, card_name: str) -> Set[str]:
|
|||
Args:
|
||||
text: Card text
|
||||
card_name: Card name
|
||||
keywords: Optional keywords field for static keyword detection
|
||||
|
||||
Returns:
|
||||
Set of metadata tags like {"Self: Indestructible", "Your Permanents: Ward"}
|
||||
|
|
@ -178,7 +141,7 @@ def get_protection_scope_tags(text: str, card_name: str) -> Set[str]:
|
|||
|
||||
# Check each protection ability
|
||||
for ability in PROTECTION_ABILITIES:
|
||||
scope = detect_protection_scope(text, card_name, ability)
|
||||
scope = detect_protection_scope(text, card_name, ability, keywords)
|
||||
|
||||
if scope:
|
||||
# Format: "{Scope}: {Ability}"
|
||||
|
|
|
|||
455
code/tagging/regex_patterns.py
Normal file
455
code/tagging/regex_patterns.py
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
"""
|
||||
Centralized regex patterns for MTG card tagging.
|
||||
|
||||
All patterns compiled with re.IGNORECASE for case-insensitive matching.
|
||||
Organized by semantic category for maintainability and reusability.
|
||||
|
||||
Usage:
|
||||
from code.tagging import regex_patterns as rgx
|
||||
|
||||
mask = df['text'].str.contains(rgx.YOU_CONTROL, na=False)
|
||||
if rgx.GRANT_HEXPROOF.search(text):
|
||||
...
|
||||
|
||||
# Or use builder functions
|
||||
pattern = rgx.ownership_pattern('creature', 'you')
|
||||
mask = df['text'].str.contains(pattern, na=False)
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Pattern, List
|
||||
|
||||
# =============================================================================
|
||||
# OWNERSHIP & CONTROLLER PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
YOU_CONTROL: Pattern = re.compile(r'you control', re.IGNORECASE)
|
||||
THEY_CONTROL: Pattern = re.compile(r'they control', re.IGNORECASE)
|
||||
OPPONENT_CONTROL: Pattern = re.compile(r'opponent[s]? control', re.IGNORECASE)
|
||||
|
||||
CREATURE_YOU_CONTROL: Pattern = re.compile(r'creature[s]? you control', re.IGNORECASE)
|
||||
PERMANENT_YOU_CONTROL: Pattern = re.compile(r'permanent[s]? you control', re.IGNORECASE)
|
||||
ARTIFACT_YOU_CONTROL: Pattern = re.compile(r'artifact[s]? you control', re.IGNORECASE)
|
||||
ENCHANTMENT_YOU_CONTROL: Pattern = re.compile(r'enchantment[s]? you control', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# GRANT VERB PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
GAIN: Pattern = re.compile(r'\bgain[s]?\b', re.IGNORECASE)
|
||||
HAS: Pattern = re.compile(r'\bhas\b', re.IGNORECASE)
|
||||
HAVE: Pattern = re.compile(r'\bhave\b', re.IGNORECASE)
|
||||
GET: Pattern = re.compile(r'\bget[s]?\b', re.IGNORECASE)
|
||||
|
||||
GRANT_VERBS: List[str] = ['gain', 'gains', 'has', 'have', 'get', 'gets']
|
||||
|
||||
# =============================================================================
|
||||
# TARGETING PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
TARGET_PLAYER: Pattern = re.compile(r'target player', re.IGNORECASE)
|
||||
TARGET_OPPONENT: Pattern = re.compile(r'target opponent', re.IGNORECASE)
|
||||
TARGET_CREATURE: Pattern = re.compile(r'target creature', re.IGNORECASE)
|
||||
TARGET_PERMANENT: Pattern = re.compile(r'target permanent', re.IGNORECASE)
|
||||
TARGET_ARTIFACT: Pattern = re.compile(r'target artifact', re.IGNORECASE)
|
||||
TARGET_ENCHANTMENT: Pattern = re.compile(r'target enchantment', re.IGNORECASE)
|
||||
|
||||
EACH_PLAYER: Pattern = re.compile(r'each player', re.IGNORECASE)
|
||||
EACH_OPPONENT: Pattern = re.compile(r'each opponent', re.IGNORECASE)
|
||||
TARGET_YOU_CONTROL: Pattern = re.compile(r'target .* you control', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# PROTECTION ABILITY PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
HEXPROOF: Pattern = re.compile(r'\bhexproof\b', re.IGNORECASE)
|
||||
SHROUD: Pattern = re.compile(r'\bshroud\b', re.IGNORECASE)
|
||||
INDESTRUCTIBLE: Pattern = re.compile(r'\bindestructible\b', re.IGNORECASE)
|
||||
WARD: Pattern = re.compile(r'\bward\b', re.IGNORECASE)
|
||||
PROTECTION_FROM: Pattern = re.compile(r'protection from', re.IGNORECASE)
|
||||
|
||||
PROTECTION_ABILITIES: List[str] = ['hexproof', 'shroud', 'indestructible', 'ward', 'protection']
|
||||
|
||||
CANT_HAVE_PROTECTION: Pattern = re.compile(r"can't have (hexproof|indestructible|ward|shroud)", re.IGNORECASE)
|
||||
LOSE_PROTECTION: Pattern = re.compile(r"lose[s]? (hexproof|indestructible|ward|shroud|protection)", re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# CARD DRAW PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
DRAW_A_CARD: Pattern = re.compile(r'draw[s]? (?:a|one) card', re.IGNORECASE)
|
||||
DRAW_CARDS: Pattern = re.compile(r'draw[s]? (?:two|three|four|five|x|\d+) card', re.IGNORECASE)
|
||||
DRAW: Pattern = re.compile(r'\bdraw[s]?\b', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# TOKEN CREATION PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
CREATE_TOKEN: Pattern = re.compile(r'create[s]?.*token', re.IGNORECASE)
|
||||
PUT_TOKEN: Pattern = re.compile(r'put[s]?.*token', re.IGNORECASE)
|
||||
|
||||
CREATE_TREASURE: Pattern = re.compile(r'create.*treasure token', re.IGNORECASE)
|
||||
CREATE_FOOD: Pattern = re.compile(r'create.*food token', re.IGNORECASE)
|
||||
CREATE_CLUE: Pattern = re.compile(r'create.*clue token', re.IGNORECASE)
|
||||
CREATE_BLOOD: Pattern = re.compile(r'create.*blood token', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# COUNTER PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
PLUS_ONE_COUNTER: Pattern = re.compile(r'\+1/\+1 counter', re.IGNORECASE)
|
||||
MINUS_ONE_COUNTER: Pattern = re.compile(r'\-1/\-1 counter', re.IGNORECASE)
|
||||
LOYALTY_COUNTER: Pattern = re.compile(r'loyalty counter', re.IGNORECASE)
|
||||
PROLIFERATE: Pattern = re.compile(r'\bproliferate\b', re.IGNORECASE)
|
||||
|
||||
ONE_OR_MORE_COUNTERS: Pattern = re.compile(r'one or more counter', re.IGNORECASE)
|
||||
ONE_OR_MORE_PLUS_ONE_COUNTERS: Pattern = re.compile(r'one or more \+1/\+1 counter', re.IGNORECASE)
|
||||
IF_HAD_COUNTERS: Pattern = re.compile(r'if it had counter', re.IGNORECASE)
|
||||
WITH_COUNTERS_ON_THEM: Pattern = re.compile(r'with counter[s]? on them', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# SACRIFICE & REMOVAL PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
SACRIFICE: Pattern = re.compile(r'sacrifice[s]?', re.IGNORECASE)
|
||||
SACRIFICED: Pattern = re.compile(r'sacrificed', re.IGNORECASE)
|
||||
DESTROY: Pattern = re.compile(r'destroy[s]?', re.IGNORECASE)
|
||||
EXILE: Pattern = re.compile(r'exile[s]?', re.IGNORECASE)
|
||||
EXILED: Pattern = re.compile(r'exiled', re.IGNORECASE)
|
||||
|
||||
SACRIFICE_DRAW: Pattern = re.compile(r'sacrifice (?:a|an) (?:artifact|creature|permanent)(?:[^,]*),?[^,]*draw', re.IGNORECASE)
|
||||
SACRIFICE_COLON_DRAW: Pattern = re.compile(r'sacrifice [^:]+: draw', re.IGNORECASE)
|
||||
SACRIFICED_COMMA_DRAW: Pattern = re.compile(r'sacrificed[^,]+, draw', re.IGNORECASE)
|
||||
EXILE_RETURN_BATTLEFIELD: Pattern = re.compile(r'exile.*return.*to the battlefield', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# DISCARD PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
DISCARD_A_CARD: Pattern = re.compile(r'discard (?:a|one|two|three|x) card', re.IGNORECASE)
|
||||
DISCARD_YOUR_HAND: Pattern = re.compile(r'discard your hand', re.IGNORECASE)
|
||||
YOU_DISCARD: Pattern = re.compile(r'you discard', re.IGNORECASE)
|
||||
|
||||
# Discard triggers
|
||||
WHENEVER_YOU_DISCARD: Pattern = re.compile(r'whenever you discard', re.IGNORECASE)
|
||||
IF_YOU_DISCARDED: Pattern = re.compile(r'if you discarded', re.IGNORECASE)
|
||||
WHEN_YOU_DISCARD: Pattern = re.compile(r'when you discard', re.IGNORECASE)
|
||||
FOR_EACH_DISCARDED: Pattern = re.compile(r'for each card you discarded', re.IGNORECASE)
|
||||
|
||||
# Opponent discard
|
||||
TARGET_PLAYER_DISCARDS: Pattern = re.compile(r'target player discards', re.IGNORECASE)
|
||||
TARGET_OPPONENT_DISCARDS: Pattern = re.compile(r'target opponent discards', re.IGNORECASE)
|
||||
EACH_PLAYER_DISCARDS: Pattern = re.compile(r'each player discards', re.IGNORECASE)
|
||||
EACH_OPPONENT_DISCARDS: Pattern = re.compile(r'each opponent discards', re.IGNORECASE)
|
||||
THAT_PLAYER_DISCARDS: Pattern = re.compile(r'that player discards', re.IGNORECASE)
|
||||
|
||||
# Discard cost
|
||||
ADDITIONAL_COST_DISCARD: Pattern = re.compile(r'as an additional cost to (?:cast this spell|activate this ability),? discard (?:a|one) card', re.IGNORECASE)
|
||||
ADDITIONAL_COST_DISCARD_SHORT: Pattern = re.compile(r'as an additional cost,? discard (?:a|one) card', re.IGNORECASE)
|
||||
|
||||
MADNESS: Pattern = re.compile(r'\bmadness\b', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# DAMAGE & LIFE LOSS PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
DEALS_ONE_DAMAGE: Pattern = re.compile(r'deals\s+1\s+damage', re.IGNORECASE)
|
||||
EXACTLY_ONE_DAMAGE: Pattern = re.compile(r'exactly\s+1\s+damage', re.IGNORECASE)
|
||||
LOSES_ONE_LIFE: Pattern = re.compile(r'loses\s+1\s+life', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# COST REDUCTION PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
COST_LESS: Pattern = re.compile(r'cost[s]? \{[\d\w]\} less', re.IGNORECASE)
|
||||
COST_LESS_TO_CAST: Pattern = re.compile(r'cost[s]? less to cast', re.IGNORECASE)
|
||||
WITH_X_IN_COST: Pattern = re.compile(r'with \{[xX]\} in (?:its|their)', re.IGNORECASE)
|
||||
AFFINITY_FOR: Pattern = re.compile(r'affinity for', re.IGNORECASE)
|
||||
SPELLS_COST: Pattern = re.compile(r'spells cost', re.IGNORECASE)
|
||||
SPELLS_YOU_CAST_COST: Pattern = re.compile(r'spells you cast cost', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# MONARCH & INITIATIVE PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
BECOME_MONARCH: Pattern = re.compile(r'becomes? the monarch', re.IGNORECASE)
|
||||
IS_MONARCH: Pattern = re.compile(r'is the monarch', re.IGNORECASE)
|
||||
WAS_MONARCH: Pattern = re.compile(r'was the monarch', re.IGNORECASE)
|
||||
YOU_ARE_MONARCH: Pattern = re.compile(r"you are the monarch|you're the monarch", re.IGNORECASE)
|
||||
YOU_BECOME_MONARCH: Pattern = re.compile(r'you become the monarch', re.IGNORECASE)
|
||||
CANT_BECOME_MONARCH: Pattern = re.compile(r"can't become the monarch", re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# KEYWORD ABILITY PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
PARTNER_BASIC: Pattern = re.compile(r'\bpartner\b(?!\s*(?:with|[-—–]))', re.IGNORECASE)
|
||||
PARTNER_WITH: Pattern = re.compile(r'partner with', re.IGNORECASE)
|
||||
PARTNER_SURVIVORS: Pattern = re.compile(r'Partner\s*[-—–]\s*Survivors', re.IGNORECASE)
|
||||
PARTNER_FATHER_SON: Pattern = re.compile(r'Partner\s*[-—–]\s*Father\s*&\s*Son', re.IGNORECASE)
|
||||
|
||||
FLYING: Pattern = re.compile(r'\bflying\b', re.IGNORECASE)
|
||||
VIGILANCE: Pattern = re.compile(r'\bvigilance\b', re.IGNORECASE)
|
||||
TRAMPLE: Pattern = re.compile(r'\btrample\b', re.IGNORECASE)
|
||||
HASTE: Pattern = re.compile(r'\bhaste\b', re.IGNORECASE)
|
||||
LIFELINK: Pattern = re.compile(r'\blifelink\b', re.IGNORECASE)
|
||||
DEATHTOUCH: Pattern = re.compile(r'\bdeathtouch\b', re.IGNORECASE)
|
||||
DOUBLE_STRIKE: Pattern = re.compile(r'double strike', re.IGNORECASE)
|
||||
FIRST_STRIKE: Pattern = re.compile(r'first strike', re.IGNORECASE)
|
||||
MENACE: Pattern = re.compile(r'\bmenace\b', re.IGNORECASE)
|
||||
REACH: Pattern = re.compile(r'\breach\b', re.IGNORECASE)
|
||||
|
||||
UNDYING: Pattern = re.compile(r'\bundying\b', re.IGNORECASE)
|
||||
PERSIST: Pattern = re.compile(r'\bpersist\b', re.IGNORECASE)
|
||||
PHASING: Pattern = re.compile(r'\bphasing\b', re.IGNORECASE)
|
||||
FLASH: Pattern = re.compile(r'\bflash\b', re.IGNORECASE)
|
||||
TOXIC: Pattern = re.compile(r'toxic\s*\d+', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# RETURN TO BATTLEFIELD PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
RETURN_TO_BATTLEFIELD: Pattern = re.compile(r'return.*to the battlefield', re.IGNORECASE)
|
||||
RETURN_IT_TO_BATTLEFIELD: Pattern = re.compile(r'return it to the battlefield', re.IGNORECASE)
|
||||
RETURN_THAT_CARD_TO_BATTLEFIELD: Pattern = re.compile(r'return that card to the battlefield', re.IGNORECASE)
|
||||
RETURN_THEM_TO_BATTLEFIELD: Pattern = re.compile(r'return them to the battlefield', re.IGNORECASE)
|
||||
RETURN_THOSE_CARDS_TO_BATTLEFIELD: Pattern = re.compile(r'return those cards to the battlefield', re.IGNORECASE)
|
||||
|
||||
RETURN_TO_HAND: Pattern = re.compile(r'return.*to.*hand', re.IGNORECASE)
|
||||
RETURN_YOU_CONTROL_TO_HAND: Pattern = re.compile(r'return target.*you control.*to.*hand', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# SCOPE & QUALIFIER PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
OTHER_CREATURES: Pattern = re.compile(r'other creature[s]?', re.IGNORECASE)
|
||||
ALL_CREATURES: Pattern = re.compile(r'\ball creature[s]?\b', re.IGNORECASE)
|
||||
ALL_PERMANENTS: Pattern = re.compile(r'\ball permanent[s]?\b', re.IGNORECASE)
|
||||
ALL_SLIVERS: Pattern = re.compile(r'\ball sliver[s]?\b', re.IGNORECASE)
|
||||
|
||||
EQUIPPED_CREATURE: Pattern = re.compile(r'equipped creature', re.IGNORECASE)
|
||||
ENCHANTED_CREATURE: Pattern = re.compile(r'enchanted creature', re.IGNORECASE)
|
||||
ENCHANTED_PERMANENT: Pattern = re.compile(r'enchanted permanent', re.IGNORECASE)
|
||||
ENCHANTED_ENCHANTMENT: Pattern = re.compile(r'enchanted enchantment', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# COMBAT PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
ATTACK: Pattern = re.compile(r'\battack[s]?\b', re.IGNORECASE)
|
||||
ATTACKS: Pattern = re.compile(r'\battacks\b', re.IGNORECASE)
|
||||
BLOCK: Pattern = re.compile(r'\bblock[s]?\b', re.IGNORECASE)
|
||||
BLOCKS: Pattern = re.compile(r'\bblocks\b', re.IGNORECASE)
|
||||
COMBAT_DAMAGE: Pattern = re.compile(r'combat damage', re.IGNORECASE)
|
||||
|
||||
WHENEVER_ATTACKS: Pattern = re.compile(r'whenever .* attacks', re.IGNORECASE)
|
||||
WHEN_ATTACKS: Pattern = re.compile(r'when .* attacks', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# TYPE LINE PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
INSTANT: Pattern = re.compile(r'\bInstant\b', re.IGNORECASE)
|
||||
SORCERY: Pattern = re.compile(r'\bSorcery\b', re.IGNORECASE)
|
||||
ARTIFACT: Pattern = re.compile(r'\bArtifact\b', re.IGNORECASE)
|
||||
ENCHANTMENT: Pattern = re.compile(r'\bEnchantment\b', re.IGNORECASE)
|
||||
CREATURE: Pattern = re.compile(r'\bCreature\b', re.IGNORECASE)
|
||||
PLANESWALKER: Pattern = re.compile(r'\bPlaneswalker\b', re.IGNORECASE)
|
||||
LAND: Pattern = re.compile(r'\bLand\b', re.IGNORECASE)
|
||||
|
||||
AURA: Pattern = re.compile(r'\bAura\b', re.IGNORECASE)
|
||||
EQUIPMENT: Pattern = re.compile(r'\bEquipment\b', re.IGNORECASE)
|
||||
VEHICLE: Pattern = re.compile(r'\bVehicle\b', re.IGNORECASE)
|
||||
SAGA: Pattern = re.compile(r'\bSaga\b', re.IGNORECASE)
|
||||
|
||||
NONCREATURE: Pattern = re.compile(r'noncreature', re.IGNORECASE)
|
||||
|
||||
# =============================================================================
|
||||
# PATTERN BUILDER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
def ownership_pattern(subject: str, owner: str = "you") -> Pattern:
|
||||
"""
|
||||
Build ownership pattern like 'creatures you control', 'permanents opponent controls'.
|
||||
|
||||
Args:
|
||||
subject: The card type (e.g., 'creature', 'permanent', 'artifact')
|
||||
owner: Controller ('you', 'opponent', 'they', etc.)
|
||||
|
||||
Returns:
|
||||
Compiled regex pattern
|
||||
|
||||
Examples:
|
||||
>>> ownership_pattern('creature', 'you')
|
||||
# Matches "creatures you control"
|
||||
>>> ownership_pattern('artifact', 'opponent')
|
||||
# Matches "artifacts opponent controls"
|
||||
"""
|
||||
pattern = fr'{subject}[s]?\s+{owner}\s+control[s]?'
|
||||
return re.compile(pattern, re.IGNORECASE)
|
||||
|
||||
|
||||
def grant_pattern(subject: str, verb: str, ability: str) -> Pattern:
|
||||
"""
|
||||
Build grant pattern like 'creatures you control gain hexproof'.
|
||||
|
||||
Args:
|
||||
subject: What gains the ability ('creatures you control', 'target creature', etc.)
|
||||
verb: Grant verb ('gain', 'has', 'get', etc.)
|
||||
ability: Ability granted ('hexproof', 'flying', 'ward', etc.)
|
||||
|
||||
Returns:
|
||||
Compiled regex pattern
|
||||
|
||||
Examples:
|
||||
>>> grant_pattern('creatures you control', 'gain', 'hexproof')
|
||||
# Matches "creatures you control gain hexproof"
|
||||
"""
|
||||
pattern = fr'{subject}\s+{verb}[s]?\s+{ability}'
|
||||
return re.compile(pattern, re.IGNORECASE)
|
||||
|
||||
|
||||
def token_creation_pattern(quantity: str, token_type: str) -> Pattern:
|
||||
"""
|
||||
Build token creation pattern like 'create two 1/1 Soldier tokens'.
|
||||
|
||||
Args:
|
||||
quantity: Number word or variable ('one', 'two', 'x', etc.)
|
||||
token_type: Token name ('treasure', 'food', 'soldier', etc.)
|
||||
|
||||
Returns:
|
||||
Compiled regex pattern
|
||||
|
||||
Examples:
|
||||
>>> token_creation_pattern('two', 'treasure')
|
||||
# Matches "create two Treasure tokens"
|
||||
"""
|
||||
pattern = fr'create[s]?\s+(?:{quantity})\s+.*{token_type}\s+token'
|
||||
return re.compile(pattern, re.IGNORECASE)
|
||||
|
||||
|
||||
def kindred_grant_pattern(tribe: str, ability: str) -> Pattern:
|
||||
"""
|
||||
Build kindred grant pattern like 'knights you control gain protection'.
|
||||
|
||||
Args:
|
||||
tribe: Creature type ('knight', 'elf', 'zombie', etc.)
|
||||
ability: Ability granted ('hexproof', 'protection', etc.)
|
||||
|
||||
Returns:
|
||||
Compiled regex pattern
|
||||
|
||||
Examples:
|
||||
>>> kindred_grant_pattern('knight', 'hexproof')
|
||||
# Matches "Knights you control gain hexproof"
|
||||
"""
|
||||
pattern = fr'{tribe}[s]?\s+you\s+control.*\b{ability}\b'
|
||||
return re.compile(pattern, re.IGNORECASE)
|
||||
|
||||
|
||||
def targeting_pattern(target: str, subject: str = None) -> Pattern:
|
||||
"""
|
||||
Build targeting pattern like 'target creature you control'.
|
||||
|
||||
Args:
|
||||
target: What is targeted ('player', 'opponent', 'creature', etc.)
|
||||
subject: Optional qualifier ('you control', 'opponent controls', etc.)
|
||||
|
||||
Returns:
|
||||
Compiled regex pattern
|
||||
|
||||
Examples:
|
||||
>>> targeting_pattern('creature', 'you control')
|
||||
# Matches "target creature you control"
|
||||
>>> targeting_pattern('opponent')
|
||||
# Matches "target opponent"
|
||||
"""
|
||||
if subject:
|
||||
pattern = fr'target\s+{target}\s+{subject}'
|
||||
else:
|
||||
pattern = fr'target\s+{target}'
|
||||
return re.compile(pattern, re.IGNORECASE)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODULE EXPORTS
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
# Ownership
|
||||
'YOU_CONTROL', 'THEY_CONTROL', 'OPPONENT_CONTROL',
|
||||
'CREATURE_YOU_CONTROL', 'PERMANENT_YOU_CONTROL', 'ARTIFACT_YOU_CONTROL',
|
||||
'ENCHANTMENT_YOU_CONTROL',
|
||||
|
||||
# Grant verbs
|
||||
'GAIN', 'HAS', 'HAVE', 'GET', 'GRANT_VERBS',
|
||||
|
||||
# Targeting
|
||||
'TARGET_PLAYER', 'TARGET_OPPONENT', 'TARGET_CREATURE', 'TARGET_PERMANENT',
|
||||
'TARGET_ARTIFACT', 'TARGET_ENCHANTMENT', 'EACH_PLAYER', 'EACH_OPPONENT',
|
||||
'TARGET_YOU_CONTROL',
|
||||
|
||||
# Protection abilities
|
||||
'HEXPROOF', 'SHROUD', 'INDESTRUCTIBLE', 'WARD', 'PROTECTION_FROM',
|
||||
'PROTECTION_ABILITIES', 'CANT_HAVE_PROTECTION', 'LOSE_PROTECTION',
|
||||
|
||||
# Draw
|
||||
'DRAW_A_CARD', 'DRAW_CARDS', 'DRAW',
|
||||
|
||||
# Tokens
|
||||
'CREATE_TOKEN', 'PUT_TOKEN',
|
||||
'CREATE_TREASURE', 'CREATE_FOOD', 'CREATE_CLUE', 'CREATE_BLOOD',
|
||||
|
||||
# Counters
|
||||
'PLUS_ONE_COUNTER', 'MINUS_ONE_COUNTER', 'LOYALTY_COUNTER', 'PROLIFERATE',
|
||||
'ONE_OR_MORE_COUNTERS', 'ONE_OR_MORE_PLUS_ONE_COUNTERS', 'IF_HAD_COUNTERS', 'WITH_COUNTERS_ON_THEM',
|
||||
|
||||
# Removal
|
||||
'SACRIFICE', 'SACRIFICED', 'DESTROY', 'EXILE', 'EXILED',
|
||||
'SACRIFICE_DRAW', 'SACRIFICE_COLON_DRAW', 'SACRIFICED_COMMA_DRAW',
|
||||
'EXILE_RETURN_BATTLEFIELD',
|
||||
|
||||
# Discard
|
||||
'DISCARD_A_CARD', 'DISCARD_YOUR_HAND', 'YOU_DISCARD',
|
||||
'WHENEVER_YOU_DISCARD', 'IF_YOU_DISCARDED', 'WHEN_YOU_DISCARD', 'FOR_EACH_DISCARDED',
|
||||
'TARGET_PLAYER_DISCARDS', 'TARGET_OPPONENT_DISCARDS', 'EACH_PLAYER_DISCARDS',
|
||||
'EACH_OPPONENT_DISCARDS', 'THAT_PLAYER_DISCARDS',
|
||||
'ADDITIONAL_COST_DISCARD', 'ADDITIONAL_COST_DISCARD_SHORT', 'MADNESS',
|
||||
|
||||
# Damage & Life Loss
|
||||
'DEALS_ONE_DAMAGE', 'EXACTLY_ONE_DAMAGE', 'LOSES_ONE_LIFE',
|
||||
|
||||
# Cost reduction
|
||||
'COST_LESS', 'COST_LESS_TO_CAST', 'WITH_X_IN_COST', 'AFFINITY_FOR', 'SPELLS_COST', 'SPELLS_YOU_CAST_COST',
|
||||
|
||||
# Monarch
|
||||
'BECOME_MONARCH', 'IS_MONARCH', 'WAS_MONARCH', 'YOU_ARE_MONARCH',
|
||||
'YOU_BECOME_MONARCH', 'CANT_BECOME_MONARCH',
|
||||
|
||||
# Keywords
|
||||
'PARTNER_BASIC', 'PARTNER_WITH', 'PARTNER_SURVIVORS', 'PARTNER_FATHER_SON',
|
||||
'FLYING', 'VIGILANCE', 'TRAMPLE', 'HASTE', 'LIFELINK', 'DEATHTOUCH',
|
||||
'DOUBLE_STRIKE', 'FIRST_STRIKE', 'MENACE', 'REACH',
|
||||
'UNDYING', 'PERSIST', 'PHASING', 'FLASH', 'TOXIC',
|
||||
|
||||
# Return
|
||||
'RETURN_TO_BATTLEFIELD', 'RETURN_IT_TO_BATTLEFIELD', 'RETURN_THAT_CARD_TO_BATTLEFIELD',
|
||||
'RETURN_THEM_TO_BATTLEFIELD', 'RETURN_THOSE_CARDS_TO_BATTLEFIELD',
|
||||
'RETURN_TO_HAND', 'RETURN_YOU_CONTROL_TO_HAND',
|
||||
|
||||
# Scope
|
||||
'OTHER_CREATURES', 'ALL_CREATURES', 'ALL_PERMANENTS', 'ALL_SLIVERS',
|
||||
'EQUIPPED_CREATURE', 'ENCHANTED_CREATURE', 'ENCHANTED_PERMANENT', 'ENCHANTED_ENCHANTMENT',
|
||||
|
||||
# Combat
|
||||
'ATTACK', 'ATTACKS', 'BLOCK', 'BLOCKS', 'COMBAT_DAMAGE',
|
||||
'WHENEVER_ATTACKS', 'WHEN_ATTACKS',
|
||||
|
||||
# Type line
|
||||
'INSTANT', 'SORCERY', 'ARTIFACT', 'ENCHANTMENT', 'CREATURE', 'PLANESWALKER', 'LAND',
|
||||
'AURA', 'EQUIPMENT', 'VEHICLE', 'SAGA', 'NONCREATURE',
|
||||
|
||||
# Builders
|
||||
'ownership_pattern', 'grant_pattern', 'token_creation_pattern',
|
||||
'kindred_grant_pattern', 'targeting_pattern',
|
||||
]
|
||||
420
code/tagging/scope_detection_utils.py
Normal file
420
code/tagging/scope_detection_utils.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
"""
|
||||
Scope Detection Utilities
|
||||
|
||||
Generic utilities for detecting the scope of card abilities (protection, phasing, etc.).
|
||||
Provides reusable pattern-matching logic to avoid duplication across modules.
|
||||
|
||||
Created as part of M2: Create Scope Detection Utilities milestone.
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Set
|
||||
|
||||
# Local application imports
|
||||
from . import regex_patterns as rgx
|
||||
from . import tag_utils
|
||||
from code.logging_util import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScopePatterns:
|
||||
"""
|
||||
Pattern collections for scope detection.
|
||||
|
||||
Attributes:
|
||||
opponent: Patterns that indicate opponent ownership
|
||||
self_ref: Patterns that indicate self-reference
|
||||
your_permanents: Patterns that indicate "you control"
|
||||
blanket: Patterns that indicate no ownership qualifier
|
||||
targeted: Patterns that indicate targeting (optional)
|
||||
"""
|
||||
opponent: List[re.Pattern]
|
||||
self_ref: List[re.Pattern]
|
||||
your_permanents: List[re.Pattern]
|
||||
blanket: List[re.Pattern]
|
||||
targeted: Optional[List[re.Pattern]] = None
|
||||
|
||||
|
||||
def detect_scope(
|
||||
text: str,
|
||||
card_name: str,
|
||||
ability_keyword: str,
|
||||
patterns: ScopePatterns,
|
||||
allow_multiple: bool = False,
|
||||
check_grant_verbs: bool = False,
|
||||
keywords: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Generic scope detection with priority ordering.
|
||||
|
||||
Detection priority (prevents misclassification):
|
||||
0. Static keyword (in keywords field or simple list) → "Self"
|
||||
1. Opponent ownership → "Opponent Permanents"
|
||||
2. Self-reference → "Self"
|
||||
3. Your ownership → "Your Permanents"
|
||||
4. No ownership qualifier → "Blanket"
|
||||
|
||||
Args:
|
||||
text: Card text
|
||||
card_name: Card name (for self-reference detection)
|
||||
ability_keyword: Ability keyword to look for (e.g., "hexproof", "phasing")
|
||||
patterns: ScopePatterns object with pattern collections
|
||||
allow_multiple: If True, returns Set[str] instead of single scope
|
||||
check_grant_verbs: If True, checks for grant verbs before assuming "Self"
|
||||
keywords: Optional keywords field from card data (for static keyword detection)
|
||||
|
||||
Returns:
|
||||
Scope string or None: "Self", "Your Permanents", "Blanket", "Opponent Permanents"
|
||||
If allow_multiple=True, returns Set[str] with all matching scopes
|
||||
"""
|
||||
if not text or not ability_keyword:
|
||||
return set() if allow_multiple else None
|
||||
|
||||
text_lower = text.lower()
|
||||
ability_lower = ability_keyword.lower()
|
||||
card_name_lower = card_name.lower() if card_name else ''
|
||||
|
||||
# Check if ability is mentioned in text
|
||||
if ability_lower not in text_lower:
|
||||
return set() if allow_multiple else None
|
||||
|
||||
# Priority 0: Check if this is a static keyword ability
|
||||
# Static keywords appear in the keywords field or as simple comma-separated lists
|
||||
# without grant verbs (e.g., "Flying, first strike, protection from black")
|
||||
if check_static_keyword(ability_keyword, keywords, text):
|
||||
if allow_multiple:
|
||||
return {"Self"}
|
||||
else:
|
||||
return "Self"
|
||||
|
||||
if allow_multiple:
|
||||
scopes = set()
|
||||
else:
|
||||
scopes = None
|
||||
|
||||
# Priority 1: Opponent ownership
|
||||
for pattern in patterns.opponent:
|
||||
if pattern.search(text_lower):
|
||||
if allow_multiple:
|
||||
scopes.add("Opponent Permanents")
|
||||
break
|
||||
else:
|
||||
return "Opponent Permanents"
|
||||
|
||||
# Priority 2: Self-reference
|
||||
is_self = _check_self_reference(text_lower, card_name_lower, ability_lower, patterns.self_ref)
|
||||
|
||||
# If check_grant_verbs is True, verify we don't have grant patterns before assuming Self
|
||||
if is_self and check_grant_verbs:
|
||||
has_grant_pattern = _has_grant_verbs(text_lower)
|
||||
if not has_grant_pattern:
|
||||
if allow_multiple:
|
||||
scopes.add("Self")
|
||||
else:
|
||||
return "Self"
|
||||
elif is_self:
|
||||
if allow_multiple:
|
||||
scopes.add("Self")
|
||||
else:
|
||||
return "Self"
|
||||
|
||||
# Priority 3: Your ownership
|
||||
for pattern in patterns.your_permanents:
|
||||
if pattern.search(text_lower):
|
||||
if allow_multiple:
|
||||
scopes.add("Your Permanents")
|
||||
break
|
||||
else:
|
||||
return "Your Permanents"
|
||||
|
||||
# Priority 4: Blanket (no ownership qualifier)
|
||||
for pattern in patterns.blanket:
|
||||
if pattern.search(text_lower):
|
||||
# Double-check no ownership was missed
|
||||
if not rgx.YOU_CONTROL.search(text_lower) and 'opponent' not in text_lower:
|
||||
if allow_multiple:
|
||||
scopes.add("Blanket")
|
||||
break
|
||||
else:
|
||||
return "Blanket"
|
||||
|
||||
return scopes if allow_multiple else None
|
||||
|
||||
|
||||
def detect_multi_scope(
|
||||
text: str,
|
||||
card_name: str,
|
||||
ability_keyword: str,
|
||||
patterns: ScopePatterns,
|
||||
check_grant_verbs: bool = False,
|
||||
keywords: Optional[str] = None,
|
||||
) -> Set[str]:
|
||||
"""
|
||||
Detect multiple scopes for cards with multiple effects.
|
||||
|
||||
Some cards grant abilities to multiple scopes:
|
||||
- Self-hexproof + grants ward to others
|
||||
- Target phasing + your permanents phasing
|
||||
|
||||
Args:
|
||||
text: Card text
|
||||
card_name: Card name
|
||||
ability_keyword: Ability keyword to look for
|
||||
patterns: ScopePatterns object
|
||||
check_grant_verbs: If True, checks for grant verbs before assuming "Self"
|
||||
keywords: Optional keywords field for static keyword detection
|
||||
|
||||
Returns:
|
||||
Set of scope strings
|
||||
"""
|
||||
scopes = set()
|
||||
|
||||
if not text or not ability_keyword:
|
||||
return scopes
|
||||
|
||||
text_lower = text.lower()
|
||||
ability_lower = ability_keyword.lower()
|
||||
card_name_lower = card_name.lower() if card_name else ''
|
||||
|
||||
# Check for static keyword first
|
||||
if check_static_keyword(ability_keyword, keywords, text):
|
||||
scopes.add("Self")
|
||||
# For static keywords, we usually don't have multiple scopes
|
||||
# But continue checking in case there are additional effects
|
||||
|
||||
# Check if ability is mentioned
|
||||
if ability_lower not in text_lower:
|
||||
return scopes
|
||||
|
||||
# Check opponent patterns
|
||||
if any(pattern.search(text_lower) for pattern in patterns.opponent):
|
||||
scopes.add("Opponent Permanents")
|
||||
|
||||
# Check self-reference
|
||||
is_self = _check_self_reference(text_lower, card_name_lower, ability_lower, patterns.self_ref)
|
||||
|
||||
if is_self:
|
||||
if check_grant_verbs:
|
||||
has_grant_pattern = _has_grant_verbs(text_lower)
|
||||
if not has_grant_pattern:
|
||||
scopes.add("Self")
|
||||
else:
|
||||
scopes.add("Self")
|
||||
|
||||
# Check your permanents
|
||||
if any(pattern.search(text_lower) for pattern in patterns.your_permanents):
|
||||
scopes.add("Your Permanents")
|
||||
|
||||
# Check blanket (no ownership)
|
||||
has_blanket = any(pattern.search(text_lower) for pattern in patterns.blanket)
|
||||
no_ownership = not rgx.YOU_CONTROL.search(text_lower) and 'opponent' not in text_lower
|
||||
|
||||
if has_blanket and no_ownership:
|
||||
scopes.add("Blanket")
|
||||
|
||||
# Optional: Check for targeting
|
||||
if patterns.targeted:
|
||||
if any(pattern.search(text_lower) for pattern in patterns.targeted):
|
||||
scopes.add("Targeted")
|
||||
|
||||
return scopes
|
||||
|
||||
|
||||
def _check_self_reference(
|
||||
text_lower: str,
|
||||
card_name_lower: str,
|
||||
ability_lower: str,
|
||||
self_patterns: List[re.Pattern]
|
||||
) -> bool:
|
||||
"""
|
||||
Check if text contains self-reference patterns.
|
||||
|
||||
Args:
|
||||
text_lower: Lowercase card text
|
||||
card_name_lower: Lowercase card name
|
||||
ability_lower: Lowercase ability keyword
|
||||
self_patterns: List of self-reference patterns
|
||||
|
||||
Returns:
|
||||
True if self-reference found
|
||||
"""
|
||||
# Check provided self patterns
|
||||
for pattern in self_patterns:
|
||||
if pattern.search(text_lower):
|
||||
return True
|
||||
|
||||
# Check for card name reference (if provided)
|
||||
if card_name_lower:
|
||||
card_name_escaped = re.escape(card_name_lower)
|
||||
card_name_pattern = re.compile(rf'\b{card_name_escaped}\b', re.IGNORECASE)
|
||||
|
||||
if card_name_pattern.search(text_lower):
|
||||
# Make sure it's in a self-ability context
|
||||
self_context_patterns = [
|
||||
re.compile(rf'\b{card_name_escaped}\s+(?:has|gains?)\s+{ability_lower}', re.IGNORECASE),
|
||||
re.compile(rf'\b{card_name_escaped}\s+is\s+{ability_lower}', re.IGNORECASE),
|
||||
]
|
||||
|
||||
for pattern in self_context_patterns:
|
||||
if pattern.search(text_lower):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _has_grant_verbs(text_lower: str) -> bool:
|
||||
"""
|
||||
Check if text contains grant verb patterns.
|
||||
|
||||
Used to distinguish inherent abilities from granted abilities.
|
||||
|
||||
Args:
|
||||
text_lower: Lowercase card text
|
||||
|
||||
Returns:
|
||||
True if grant verbs found
|
||||
"""
|
||||
grant_patterns = [
|
||||
re.compile(r'(?:have|gain|grant|give|get)[s]?\s+', re.IGNORECASE),
|
||||
rgx.OTHER_CREATURES,
|
||||
rgx.CREATURE_YOU_CONTROL,
|
||||
rgx.PERMANENT_YOU_CONTROL,
|
||||
rgx.EQUIPPED_CREATURE,
|
||||
rgx.ENCHANTED_CREATURE,
|
||||
rgx.TARGET_CREATURE,
|
||||
]
|
||||
|
||||
return any(pattern.search(text_lower) for pattern in grant_patterns)
|
||||
|
||||
|
||||
def format_scope_tag(scope: str, ability: str) -> str:
|
||||
"""
|
||||
Format a scope and ability into a metadata tag.
|
||||
|
||||
Args:
|
||||
scope: Scope string (e.g., "Self", "Your Permanents")
|
||||
ability: Ability name (e.g., "Hexproof", "Phasing")
|
||||
|
||||
Returns:
|
||||
Formatted tag string (e.g., "Self: Hexproof")
|
||||
"""
|
||||
return f"{scope}: {ability}"
|
||||
|
||||
|
||||
def has_keyword(text: str, keywords: List[str]) -> bool:
|
||||
"""
|
||||
Quick check if card text contains any of the specified keywords.
|
||||
|
||||
Args:
|
||||
text: Card text
|
||||
keywords: List of keywords to search for
|
||||
|
||||
Returns:
|
||||
True if any keyword found
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
text_lower = text.lower()
|
||||
return any(keyword.lower() in text_lower for keyword in keywords)
|
||||
|
||||
|
||||
def check_static_keyword(
|
||||
ability_keyword: str,
|
||||
keywords: Optional[str] = None,
|
||||
text: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if card has ability as a static keyword (not granted to others).
|
||||
|
||||
A static keyword is one that appears:
|
||||
1. In the keywords field, OR
|
||||
2. As a simple comma-separated list without grant verbs
|
||||
(e.g., "Flying, first strike, protection from black")
|
||||
|
||||
Args:
|
||||
ability_keyword: Ability to check (e.g., "Protection", "Hexproof")
|
||||
keywords: Optional keywords field from card data
|
||||
text: Optional card text for fallback detection
|
||||
|
||||
Returns:
|
||||
True if ability appears as static keyword
|
||||
"""
|
||||
ability_lower = ability_keyword.lower()
|
||||
|
||||
# Check keywords field first (most reliable)
|
||||
if keywords:
|
||||
keywords_lower = keywords.lower()
|
||||
if ability_lower in keywords_lower:
|
||||
return True
|
||||
|
||||
# Fallback: Check if ability appears in simple comma-separated keyword list
|
||||
# Pattern: starts with keywords (Flying, First strike, etc.) without grant verbs
|
||||
# Example: "Flying, first strike, vigilance, trample, haste, protection from black"
|
||||
if text:
|
||||
text_lower = text.lower()
|
||||
|
||||
# Check if ability appears in text but WITHOUT grant verbs
|
||||
if ability_lower in text_lower:
|
||||
# Look for grant verbs that would indicate this is NOT a static keyword
|
||||
grant_verbs = ['have', 'has', 'gain', 'gains', 'get', 'gets', 'grant', 'grants', 'give', 'gives']
|
||||
|
||||
# Find the position of the ability in text
|
||||
ability_pos = text_lower.find(ability_lower)
|
||||
|
||||
# Check the 50 characters before the ability for grant verbs
|
||||
# This catches patterns like "creatures gain protection" or "has hexproof"
|
||||
context_before = text_lower[max(0, ability_pos - 50):ability_pos]
|
||||
|
||||
# If no grant verbs found nearby, it's likely a static keyword
|
||||
if not any(verb in context_before for verb in grant_verbs):
|
||||
# Additional check: is it part of a comma-separated list?
|
||||
# This helps with "Flying, first strike, protection from X" patterns
|
||||
context_before_30 = text_lower[max(0, ability_pos - 30):ability_pos]
|
||||
if ',' in context_before_30 or ability_pos < 10:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_static_keyword_legacy(
|
||||
keywords: str,
|
||||
static_keyword: str,
|
||||
text: str,
|
||||
grant_patterns: Optional[List[re.Pattern]] = None
|
||||
) -> bool:
|
||||
"""
|
||||
LEGACY: Check if card has static keyword without granting it to others.
|
||||
|
||||
Used for abilities like "Phasing" that can be both static and granted.
|
||||
|
||||
Args:
|
||||
keywords: Card keywords field
|
||||
static_keyword: Keyword to search for (e.g., "phasing")
|
||||
text: Card text
|
||||
grant_patterns: Optional patterns to check for granting language
|
||||
|
||||
Returns:
|
||||
True if static keyword found and not granted to others
|
||||
"""
|
||||
if not keywords:
|
||||
return False
|
||||
|
||||
keywords_lower = keywords.lower()
|
||||
|
||||
if static_keyword.lower() not in keywords_lower:
|
||||
return False
|
||||
|
||||
# If grant patterns provided, check if card grants to others
|
||||
if grant_patterns:
|
||||
text_no_reminder = tag_utils.strip_reminder_text(text.lower()) if text else ''
|
||||
grants_to_others = any(pattern.search(text_no_reminder) for pattern in grant_patterns)
|
||||
|
||||
# Only return True if NOT granting to others
|
||||
return not grants_to_others
|
||||
|
||||
return True
|
||||
|
|
@ -1,13 +1,59 @@
|
|||
from typing import Dict, List, Final
|
||||
"""
|
||||
Tag Constants Module
|
||||
|
||||
Centralized constants for card tagging and theme detection across the MTG deckbuilder.
|
||||
This module contains all shared constants used by the tagging system including:
|
||||
- Card types and creature types
|
||||
- Pattern groups and regex fragments
|
||||
- Tag groupings and relationships
|
||||
- Protection and ability keywords
|
||||
- Magic numbers and thresholds
|
||||
"""
|
||||
|
||||
from typing import Dict, Final, List
|
||||
|
||||
# =============================================================================
|
||||
# TABLE OF CONTENTS
|
||||
# =============================================================================
|
||||
# 1. TRIGGERS & BASIC PATTERNS
|
||||
# 2. TAG GROUPS & RELATIONSHIPS
|
||||
# 3. PATTERN GROUPS & REGEX FRAGMENTS
|
||||
# 4. PHRASE GROUPS
|
||||
# 5. COUNTER TYPES
|
||||
# 6. CREATURE TYPES
|
||||
# 7. NON-CREATURE TYPES & SPECIAL TYPES
|
||||
# 8. PROTECTION & ABILITY KEYWORDS
|
||||
# 9. TOKEN TYPES
|
||||
# 10. MAGIC NUMBERS & THRESHOLDS
|
||||
# 11. DATAFRAME COLUMN REQUIREMENTS
|
||||
# 12. TYPE-TAG MAPPINGS
|
||||
# 13. DRAW-RELATED CONSTANTS
|
||||
# 14. EQUIPMENT-RELATED CONSTANTS
|
||||
# 15. AURA & VOLTRON CONSTANTS
|
||||
# 16. LANDS MATTER PATTERNS
|
||||
# 17. SACRIFICE & GRAVEYARD PATTERNS
|
||||
# 18. CREATURE-RELATED PATTERNS
|
||||
# 19. TOKEN-RELATED PATTERNS
|
||||
# 20. REMOVAL & DESTRUCTION PATTERNS
|
||||
# 21. SPELL-RELATED PATTERNS
|
||||
# 22. MISC PATTERNS & EXCLUSIONS
|
||||
|
||||
# =============================================================================
|
||||
# 1. TRIGGERS & BASIC PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
TRIGGERS: List[str] = ['when', 'whenever', 'at']
|
||||
|
||||
NUM_TO_SEARCH: List[str] = ['a', 'an', 'one', '1', 'two', '2', 'three', '3', 'four','4', 'five', '5',
|
||||
NUM_TO_SEARCH: List[str] = [
|
||||
'a', 'an', 'one', '1', 'two', '2', 'three', '3', 'four', '4', 'five', '5',
|
||||
'six', '6', 'seven', '7', 'eight', '8', 'nine', '9', 'ten', '10',
|
||||
'x','one or more']
|
||||
'x', 'one or more'
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# 2. TAG GROUPS & RELATIONSHIPS
|
||||
# =============================================================================
|
||||
|
||||
# Constants for common tag groupings
|
||||
TAG_GROUPS: Dict[str, List[str]] = {
|
||||
"Cantrips": ["Cantrips", "Card Draw", "Spellslinger", "Spells Matter"],
|
||||
"Tokens": ["Token Creation", "Tokens Matter"],
|
||||
|
|
@ -19,7 +65,10 @@ TAG_GROUPS: Dict[str, List[str]] = {
|
|||
"Spells": ["Spellslinger", "Spells Matter"]
|
||||
}
|
||||
|
||||
# Common regex patterns
|
||||
# =============================================================================
|
||||
# 3. PATTERN GROUPS & REGEX FRAGMENTS
|
||||
# =============================================================================
|
||||
|
||||
PATTERN_GROUPS: Dict[str, str] = {
|
||||
"draw": r"draw[s]? a card|draw[s]? one card",
|
||||
"combat": r"attack[s]?|block[s]?|combat damage",
|
||||
|
|
@ -30,7 +79,10 @@ PATTERN_GROUPS: Dict[str, str] = {
|
|||
"cost_reduction": r"cost[s]? \{[\d\w]\} less|affinity for|cost[s]? less to cast|chosen type cost|copy cost|from exile cost|from exile this turn cost|from your graveyard cost|has undaunted|have affinity for artifacts|other than your hand cost|spells cost|spells you cast cost|that target .* cost|those spells cost|you cast cost|you pay cost"
|
||||
}
|
||||
|
||||
# Common phrase groups (lists) used across taggers
|
||||
# =============================================================================
|
||||
# 4. PHRASE GROUPS
|
||||
# =============================================================================
|
||||
|
||||
PHRASE_GROUPS: Dict[str, List[str]] = {
|
||||
# Variants for monarch wording
|
||||
"monarch": [
|
||||
|
|
@ -52,11 +104,15 @@ PHRASE_GROUPS: Dict[str, List[str]] = {
|
|||
r"return .* to the battlefield"
|
||||
]
|
||||
}
|
||||
# Common action patterns
|
||||
|
||||
CREATE_ACTION_PATTERN: Final[str] = r"create|put"
|
||||
|
||||
# Creature/Counter types
|
||||
COUNTER_TYPES: List[str] = [r'\+0/\+1', r'\+0/\+2', r'\+1/\+0', r'\+1/\+2', r'\+2/\+0', r'\+2/\+2',
|
||||
# =============================================================================
|
||||
# 5. COUNTER TYPES
|
||||
# =============================================================================
|
||||
|
||||
COUNTER_TYPES: List[str] = [
|
||||
r'\+0/\+1', r'\+0/\+2', r'\+1/\+0', r'\+1/\+2', r'\+2/\+0', r'\+2/\+2',
|
||||
'-0/-1', '-0/-2', '-1/-0', '-1/-2', '-2/-0', '-2/-2',
|
||||
'Acorn', 'Aegis', 'Age', 'Aim', 'Arrow', 'Arrowhead','Awakening',
|
||||
'Bait', 'Blaze', 'Blessing', 'Blight',' Blood', 'Bloddline',
|
||||
|
|
@ -90,9 +146,15 @@ COUNTER_TYPES: List[str] = [r'\+0/\+1', r'\+0/\+2', r'\+1/\+0', r'\+1/\+2', r'\+
|
|||
'Task', 'Ticket', 'Tide', 'Time', 'Tower', 'Training', 'Trap',
|
||||
'Treasure', 'Unity', 'Unlock', 'Valor', 'Velocity', 'Verse',
|
||||
'Vitality', 'Void', 'Volatile', 'Vortex', 'Vow', 'Voyage', 'Wage',
|
||||
'Winch', 'Wind', 'Wish']
|
||||
'Winch', 'Wind', 'Wish'
|
||||
]
|
||||
|
||||
CREATURE_TYPES: List[str] = ['Advisor', 'Aetherborn', 'Alien', 'Ally', 'Angel', 'Antelope', 'Ape', 'Archer', 'Archon', 'Armadillo',
|
||||
# =============================================================================
|
||||
# 6. CREATURE TYPES
|
||||
# =============================================================================
|
||||
|
||||
CREATURE_TYPES: List[str] = [
|
||||
'Advisor', 'Aetherborn', 'Alien', 'Ally', 'Angel', 'Antelope', 'Ape', 'Archer', 'Archon', 'Armadillo',
|
||||
'Army', 'Artificer', 'Assassin', 'Assembly-Worker', 'Astartes', 'Atog', 'Aurochs', 'Automaton',
|
||||
'Avatar', 'Azra', 'Badger', 'Balloon', 'Barbarian', 'Bard', 'Basilisk', 'Bat', 'Bear', 'Beast', 'Beaver',
|
||||
'Beeble', 'Beholder', 'Berserker', 'Bird', 'Blinkmoth', 'Boar', 'Brainiac', 'Bringer', 'Brushwagg',
|
||||
|
|
@ -122,9 +184,15 @@ CREATURE_TYPES: List[str] = ['Advisor', 'Aetherborn', 'Alien', 'Ally', 'Angel',
|
|||
'Thopter', 'Thrull', 'Tiefling', 'Time Lord', 'Toy', 'Treefolk', 'Trilobite', 'Triskelavite', 'Troll',
|
||||
'Turtle', 'Tyranid', 'Unicorn', 'Urzan', 'Vampire', 'Varmint', 'Vedalken', 'Volver', 'Wall', 'Walrus',
|
||||
'Warlock', 'Warrior', 'Wasp', 'Weasel', 'Weird', 'Werewolf', 'Whale', 'Wizard', 'Wolf', 'Wolverine', 'Wombat',
|
||||
'Worm', 'Wraith', 'Wurm', 'Yeti', 'Zombie', 'Zubera']
|
||||
'Worm', 'Wraith', 'Wurm', 'Yeti', 'Zombie', 'Zubera'
|
||||
]
|
||||
|
||||
NON_CREATURE_TYPES: List[str] = ['Legendary', 'Creature', 'Enchantment', 'Artifact',
|
||||
# =============================================================================
|
||||
# 7. NON-CREATURE TYPES & SPECIAL TYPES
|
||||
# =============================================================================
|
||||
|
||||
NON_CREATURE_TYPES: List[str] = [
|
||||
'Legendary', 'Creature', 'Enchantment', 'Artifact',
|
||||
'Battle', 'Sorcery', 'Instant', 'Land', '-', '—',
|
||||
'Blood', 'Clue', 'Food', 'Gold', 'Incubator',
|
||||
'Junk', 'Map', 'Powerstone', 'Treasure',
|
||||
|
|
@ -136,23 +204,66 @@ NON_CREATURE_TYPES: List[str] = ['Legendary', 'Creature', 'Enchantment', 'Artifa
|
|||
'Shrine',
|
||||
'Plains', 'Island', 'Swamp', 'Forest', 'Mountain',
|
||||
'Cave', 'Desert', 'Gate', 'Lair', 'Locus', 'Mine',
|
||||
'Power-Plant', 'Sphere', 'Tower', 'Urza\'s']
|
||||
'Power-Plant', 'Sphere', 'Tower', 'Urza\'s'
|
||||
]
|
||||
|
||||
OUTLAW_TYPES: List[str] = ['Assassin', 'Mercenary', 'Pirate', 'Rogue', 'Warlock']
|
||||
|
||||
ENCHANTMENT_TOKENS: List[str] = ['Cursed Role', 'Monster Role', 'Royal Role', 'Sorcerer Role',
|
||||
'Virtuous Role', 'Wicked Role', 'Young Hero Role', 'Shard']
|
||||
ARTIFACT_TOKENS: List[str] = ['Blood', 'Clue', 'Food', 'Gold', 'Incubator',
|
||||
'Junk','Map','Powerstone', 'Treasure']
|
||||
# =============================================================================
|
||||
# 8. PROTECTION & ABILITY KEYWORDS
|
||||
# =============================================================================
|
||||
|
||||
PROTECTION_ABILITIES: List[str] = [
|
||||
'Protection',
|
||||
'Ward',
|
||||
'Hexproof',
|
||||
'Shroud',
|
||||
'Indestructible'
|
||||
]
|
||||
|
||||
PROTECTION_KEYWORDS: Final[frozenset] = frozenset({
|
||||
'hexproof',
|
||||
'shroud',
|
||||
'indestructible',
|
||||
'ward',
|
||||
'protection from',
|
||||
'protection',
|
||||
})
|
||||
|
||||
# =============================================================================
|
||||
# 9. TOKEN TYPES
|
||||
# =============================================================================
|
||||
|
||||
ENCHANTMENT_TOKENS: List[str] = [
|
||||
'Cursed Role', 'Monster Role', 'Royal Role', 'Sorcerer Role',
|
||||
'Virtuous Role', 'Wicked Role', 'Young Hero Role', 'Shard'
|
||||
]
|
||||
|
||||
ARTIFACT_TOKENS: List[str] = [
|
||||
'Blood', 'Clue', 'Food', 'Gold', 'Incubator',
|
||||
'Junk', 'Map', 'Powerstone', 'Treasure'
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# 10. MAGIC NUMBERS & THRESHOLDS
|
||||
# =============================================================================
|
||||
|
||||
CONTEXT_WINDOW_SIZE: Final[int] = 70 # Characters to examine around a regex match
|
||||
|
||||
# =============================================================================
|
||||
# 11. DATAFRAME COLUMN REQUIREMENTS
|
||||
# =============================================================================
|
||||
|
||||
# Constants for DataFrame validation and processing
|
||||
REQUIRED_COLUMNS: List[str] = [
|
||||
'name', 'faceName', 'edhrecRank', 'colorIdentity', 'colors',
|
||||
'manaCost', 'manaValue', 'type', 'creatureTypes', 'text',
|
||||
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
|
||||
]
|
||||
|
||||
# Mapping of card types to their corresponding theme tags
|
||||
# =============================================================================
|
||||
# 12. TYPE-TAG MAPPINGS
|
||||
# =============================================================================
|
||||
|
||||
TYPE_TAG_MAPPING: Dict[str, List[str]] = {
|
||||
'Artifact': ['Artifacts Matter'],
|
||||
'Battle': ['Battles Matter'],
|
||||
|
|
@ -166,7 +277,10 @@ TYPE_TAG_MAPPING: Dict[str, List[str]] = {
|
|||
'Sorcery': ['Spells Matter', 'Spellslinger']
|
||||
}
|
||||
|
||||
# Constants for draw-related functionality
|
||||
# =============================================================================
|
||||
# 13. DRAW-RELATED CONSTANTS
|
||||
# =============================================================================
|
||||
|
||||
DRAW_RELATED_TAGS: List[str] = [
|
||||
'Card Draw', # General card draw effects
|
||||
'Conditional Draw', # Draw effects with conditions/triggers
|
||||
|
|
@ -178,13 +292,15 @@ DRAW_RELATED_TAGS: List[str] = [
|
|||
'Unconditional Draw' # Pure card draw without conditions
|
||||
]
|
||||
|
||||
# Text patterns that exclude cards from being tagged as unconditional draw
|
||||
DRAW_EXCLUSION_PATTERNS: List[str] = [
|
||||
'annihilator', # Eldrazi mechanic that can match 'draw' patterns
|
||||
'ravenous', # Keyword that can match 'draw' patterns
|
||||
]
|
||||
|
||||
# Equipment-related constants
|
||||
# =============================================================================
|
||||
# 14. EQUIPMENT-RELATED CONSTANTS
|
||||
# =============================================================================
|
||||
|
||||
EQUIPMENT_EXCLUSIONS: List[str] = [
|
||||
'Bruenor Battlehammer', # Equipment cost reduction
|
||||
'Nazahn, Revered Bladesmith', # Equipment tutor
|
||||
|
|
@ -223,7 +339,10 @@ EQUIPMENT_TEXT_PATTERNS: List[str] = [
|
|||
'unequip', # Equipment removal
|
||||
]
|
||||
|
||||
# Aura-related constants
|
||||
# =============================================================================
|
||||
# 15. AURA & VOLTRON CONSTANTS
|
||||
# =============================================================================
|
||||
|
||||
AURA_SPECIFIC_CARDS: List[str] = [
|
||||
'Ardenn, Intrepid Archaeologist', # Aura movement
|
||||
'Calix, Guided By Fate', # Create duplicate Auras
|
||||
|
|
@ -267,7 +386,10 @@ VOLTRON_PATTERNS: List[str] = [
|
|||
'reconfigure'
|
||||
]
|
||||
|
||||
# Constants for lands matter functionality
|
||||
# =============================================================================
|
||||
# 16. LANDS MATTER PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
LANDS_MATTER_PATTERNS: Dict[str, List[str]] = {
|
||||
'land_play': [
|
||||
'play a land',
|
||||
|
|
|
|||
|
|
@ -13,18 +13,11 @@ The module is designed to work with pandas DataFrames containing card data and p
|
|||
vectorized operations for efficient processing of large card collections.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Standard library imports
|
||||
import re
|
||||
from typing import List, Set, Union, Any, Tuple
|
||||
from functools import lru_cache
|
||||
|
||||
from typing import Any, List, Set, Tuple, Union
|
||||
import numpy as np
|
||||
|
||||
# Third-party imports
|
||||
import pandas as pd
|
||||
|
||||
# Local application imports
|
||||
from . import tag_constants
|
||||
|
||||
|
||||
|
|
@ -58,7 +51,6 @@ def _ensure_norm_series(df: pd.DataFrame, source_col: str, norm_col: str) -> pd.
|
|||
"""
|
||||
if norm_col in df.columns:
|
||||
return df[norm_col]
|
||||
# Create normalized string series
|
||||
series = df[source_col].fillna('') if source_col in df.columns else pd.Series([''] * len(df), index=df.index)
|
||||
series = series.astype(str)
|
||||
df[norm_col] = series
|
||||
|
|
@ -120,8 +112,6 @@ def create_type_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex:
|
|||
|
||||
if len(df) == 0:
|
||||
return pd.Series([], dtype=bool)
|
||||
|
||||
# Use normalized cached series
|
||||
type_series = _ensure_norm_series(df, 'type', '__type_s')
|
||||
|
||||
if regex:
|
||||
|
|
@ -160,8 +150,6 @@ def create_text_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex:
|
|||
|
||||
if len(df) == 0:
|
||||
return pd.Series([], dtype=bool)
|
||||
|
||||
# Use normalized cached series
|
||||
text_series = _ensure_norm_series(df, 'text', '__text_s')
|
||||
|
||||
if regex:
|
||||
|
|
@ -192,10 +180,7 @@ def create_keyword_mask(df: pd.DataFrame, type_text: Union[str, List[str]], rege
|
|||
TypeError: If type_text is not a string or list of strings
|
||||
ValueError: If required 'keywords' column is missing from DataFrame
|
||||
"""
|
||||
# Validate required columns
|
||||
validate_dataframe_columns(df, {'keywords'})
|
||||
|
||||
# Handle empty DataFrame case
|
||||
if len(df) == 0:
|
||||
return pd.Series([], dtype=bool)
|
||||
|
||||
|
|
@ -206,8 +191,6 @@ def create_keyword_mask(df: pd.DataFrame, type_text: Union[str, List[str]], rege
|
|||
type_text = [type_text]
|
||||
elif not isinstance(type_text, list):
|
||||
raise TypeError("type_text must be a string or list of strings")
|
||||
|
||||
# Use normalized cached series for keywords
|
||||
keywords = _ensure_norm_series(df, 'keywords', '__keywords_s')
|
||||
|
||||
if regex:
|
||||
|
|
@ -245,8 +228,6 @@ def create_name_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex:
|
|||
|
||||
if len(df) == 0:
|
||||
return pd.Series([], dtype=bool)
|
||||
|
||||
# Use normalized cached series
|
||||
name_series = _ensure_norm_series(df, 'name', '__name_s')
|
||||
|
||||
if regex:
|
||||
|
|
@ -324,21 +305,14 @@ def create_tag_mask(df: pd.DataFrame, tag_patterns: Union[str, List[str]], colum
|
|||
Boolean Series indicating matching rows
|
||||
|
||||
Examples:
|
||||
# Match cards with draw-related tags
|
||||
>>> mask = create_tag_mask(df, ['Card Draw', 'Conditional Draw'])
|
||||
>>> mask = create_tag_mask(df, 'Unconditional Draw')
|
||||
"""
|
||||
if isinstance(tag_patterns, str):
|
||||
tag_patterns = [tag_patterns]
|
||||
|
||||
# Handle empty DataFrame case
|
||||
if len(df) == 0:
|
||||
return pd.Series([], dtype=bool)
|
||||
|
||||
# Create mask for each pattern
|
||||
masks = [df[column].apply(lambda x: any(pattern in tag for tag in x)) for pattern in tag_patterns]
|
||||
|
||||
# Combine masks with OR
|
||||
return pd.concat(masks, axis=1).any(axis=1)
|
||||
|
||||
def validate_dataframe_columns(df: pd.DataFrame, required_columns: Set[str]) -> None:
|
||||
|
|
@ -365,11 +339,7 @@ def apply_tag_vectorized(df: pd.DataFrame, mask: pd.Series[bool], tags: Union[st
|
|||
"""
|
||||
if not isinstance(tags, list):
|
||||
tags = [tags]
|
||||
|
||||
# Get current tags for masked rows
|
||||
current_tags = df.loc[mask, 'themeTags']
|
||||
|
||||
# Add new tags
|
||||
df.loc[mask, 'themeTags'] = current_tags.apply(lambda x: sorted(list(set(x + tags))))
|
||||
|
||||
def apply_rules(df: pd.DataFrame, rules: List[dict]) -> None:
|
||||
|
|
@ -463,7 +433,6 @@ def create_numbered_phrase_mask(
|
|||
numbers = tag_constants.NUM_TO_SEARCH
|
||||
# Normalize verbs to list
|
||||
verbs = [verb] if isinstance(verb, str) else verb
|
||||
# Build patterns
|
||||
if noun:
|
||||
patterns = [fr"{v}\s+{num}\s+{noun}" for v in verbs for num in numbers]
|
||||
else:
|
||||
|
|
@ -490,13 +459,8 @@ def create_mass_damage_mask(df: pd.DataFrame) -> pd.Series[bool]:
|
|||
Returns:
|
||||
Boolean Series indicating which cards have mass damage effects
|
||||
"""
|
||||
# Create patterns for numeric damage
|
||||
number_patterns = [create_damage_pattern(i) for i in range(1, 21)]
|
||||
|
||||
# Add X damage pattern
|
||||
number_patterns.append(create_damage_pattern('X'))
|
||||
|
||||
# Add patterns for damage targets
|
||||
target_patterns = [
|
||||
'to each creature',
|
||||
'to all creatures',
|
||||
|
|
@ -504,8 +468,6 @@ def create_mass_damage_mask(df: pd.DataFrame) -> pd.Series[bool]:
|
|||
'to each opponent',
|
||||
'to everything'
|
||||
]
|
||||
|
||||
# Create masks
|
||||
damage_mask = create_text_mask(df, number_patterns)
|
||||
target_mask = create_text_mask(df, target_patterns)
|
||||
|
||||
|
|
@ -555,23 +517,14 @@ def normalize_keywords(
|
|||
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
|
||||
|
|
@ -659,3 +612,241 @@ def classify_tag(tag: str) -> str:
|
|||
|
||||
# Default: treat as theme tag
|
||||
return "theme"
|
||||
|
||||
|
||||
# --- Text Processing Helpers (M0.6) ---------------------------------------------------------
|
||||
def strip_reminder_text(text: str) -> str:
|
||||
"""Remove reminder text (content in parentheses) from card text.
|
||||
|
||||
Reminder text often contains keywords and patterns that can cause false positives
|
||||
in pattern matching. This function strips all parenthetical content to focus on
|
||||
the actual game text.
|
||||
|
||||
Args:
|
||||
text: Card text possibly containing reminder text in parentheses
|
||||
|
||||
Returns:
|
||||
Text with all parenthetical content removed
|
||||
|
||||
Example:
|
||||
>>> strip_reminder_text("Hexproof (This creature can't be the target of spells)")
|
||||
"Hexproof "
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
return re.sub(r'\([^)]*\)', '', text)
|
||||
|
||||
|
||||
def extract_context_window(text: str, match_start: int, match_end: int,
|
||||
window_size: int = None, include_before: bool = False) -> str:
|
||||
"""Extract a context window around a regex match for validation.
|
||||
|
||||
When pattern matching finds a potential match, we often need to examine
|
||||
the surrounding text to validate the match or check for additional keywords.
|
||||
This function extracts a window of text around the match position.
|
||||
|
||||
Args:
|
||||
text: Full text to extract context from
|
||||
match_start: Start position of the regex match
|
||||
match_end: End position of the regex match
|
||||
window_size: Number of characters to include after the match.
|
||||
If None, uses CONTEXT_WINDOW_SIZE from tag_constants (default: 70).
|
||||
To include context before the match, use include_before=True.
|
||||
include_before: If True, includes window_size characters before the match
|
||||
in addition to after. If False (default), only includes after.
|
||||
|
||||
Returns:
|
||||
Substring of text containing the match plus surrounding context
|
||||
|
||||
Example:
|
||||
>>> text = "Creatures you control have hexproof and vigilance"
|
||||
>>> match = re.search(r'creatures you control', text)
|
||||
>>> extract_context_window(text, match.start(), match.end(), window_size=30)
|
||||
'Creatures you control have hexproof and '
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
if window_size is None:
|
||||
from .tag_constants import CONTEXT_WINDOW_SIZE
|
||||
window_size = CONTEXT_WINDOW_SIZE
|
||||
|
||||
# Calculate window boundaries
|
||||
if include_before:
|
||||
context_start = max(0, match_start - window_size)
|
||||
else:
|
||||
context_start = match_start
|
||||
|
||||
context_end = min(len(text), match_end + window_size)
|
||||
|
||||
return text[context_start:context_end]
|
||||
|
||||
|
||||
# --- Enhanced Tagging Utilities (M3.5/M3.6) ----------------------------------------------------
|
||||
|
||||
def build_combined_mask(
|
||||
df: pd.DataFrame,
|
||||
text_patterns: Union[str, List[str], None] = None,
|
||||
type_patterns: Union[str, List[str], None] = None,
|
||||
keyword_patterns: Union[str, List[str], None] = None,
|
||||
name_list: Union[List[str], None] = None,
|
||||
exclusion_patterns: Union[str, List[str], None] = None,
|
||||
combine_with_or: bool = True
|
||||
) -> pd.Series[bool]:
|
||||
"""Build a combined boolean mask from multiple pattern types.
|
||||
|
||||
This utility reduces boilerplate when creating complex masks by combining
|
||||
text, type, keyword, and name patterns into a single mask. Patterns are
|
||||
combined with OR by default, but can be combined with AND.
|
||||
|
||||
Args:
|
||||
df: DataFrame to search
|
||||
text_patterns: Patterns to match in 'text' column
|
||||
type_patterns: Patterns to match in 'type' column
|
||||
keyword_patterns: Patterns to match in 'keywords' column
|
||||
name_list: List of exact card names to match
|
||||
exclusion_patterns: Text patterns to exclude from final mask
|
||||
combine_with_or: If True, combine masks with OR (default).
|
||||
If False, combine with AND (requires all conditions)
|
||||
|
||||
Returns:
|
||||
Boolean Series combining all specified patterns
|
||||
|
||||
Example:
|
||||
>>> # Match cards with flying OR haste, exclude creatures
|
||||
>>> mask = build_combined_mask(
|
||||
... df,
|
||||
... keyword_patterns=['Flying', 'Haste'],
|
||||
... exclusion_patterns='Creature'
|
||||
... )
|
||||
"""
|
||||
if combine_with_or:
|
||||
result = pd.Series([False] * len(df), index=df.index)
|
||||
else:
|
||||
result = pd.Series([True] * len(df), index=df.index)
|
||||
masks = []
|
||||
|
||||
if text_patterns is not None:
|
||||
masks.append(create_text_mask(df, text_patterns))
|
||||
|
||||
if type_patterns is not None:
|
||||
masks.append(create_type_mask(df, type_patterns))
|
||||
|
||||
if keyword_patterns is not None:
|
||||
masks.append(create_keyword_mask(df, keyword_patterns))
|
||||
|
||||
if name_list is not None:
|
||||
masks.append(create_name_mask(df, name_list))
|
||||
if masks:
|
||||
if combine_with_or:
|
||||
for mask in masks:
|
||||
result |= mask
|
||||
else:
|
||||
for mask in masks:
|
||||
result &= mask
|
||||
if exclusion_patterns is not None:
|
||||
exclusion_mask = create_text_mask(df, exclusion_patterns)
|
||||
result &= ~exclusion_mask
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def tag_with_logging(
|
||||
df: pd.DataFrame,
|
||||
mask: pd.Series[bool],
|
||||
tags: Union[str, List[str]],
|
||||
log_message: str,
|
||||
color: str = '',
|
||||
logger=None
|
||||
) -> int:
|
||||
"""Apply tags with standardized logging.
|
||||
|
||||
This utility wraps the common pattern of applying tags and logging the count.
|
||||
It provides consistent formatting for log messages across the tagging module.
|
||||
|
||||
Args:
|
||||
df: DataFrame to modify
|
||||
mask: Boolean mask indicating which rows to tag
|
||||
tags: Tag(s) to apply
|
||||
log_message: Description of what's being tagged (e.g., "flying creatures")
|
||||
color: Color identifier for context (optional)
|
||||
logger: Logger instance to use (optional, uses print if None)
|
||||
|
||||
Returns:
|
||||
Count of cards tagged
|
||||
|
||||
Example:
|
||||
>>> count = tag_with_logging(
|
||||
... df,
|
||||
... flying_mask,
|
||||
... 'Flying',
|
||||
... 'creatures with flying ability',
|
||||
... color='blue',
|
||||
... logger=logger
|
||||
... )
|
||||
# Logs: "Tagged 42 blue creatures with flying ability"
|
||||
"""
|
||||
count = mask.sum()
|
||||
if count > 0:
|
||||
apply_tag_vectorized(df, mask, tags)
|
||||
color_part = f'{color} ' if color else ''
|
||||
full_message = f'Tagged {count} {color_part}{log_message}'
|
||||
|
||||
if logger:
|
||||
logger.info(full_message)
|
||||
else:
|
||||
print(full_message)
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def tag_with_rules_and_logging(
|
||||
df: pd.DataFrame,
|
||||
rules: List[dict],
|
||||
summary_message: str,
|
||||
color: str = '',
|
||||
logger=None
|
||||
) -> int:
|
||||
"""Apply multiple tag rules with summarized logging.
|
||||
|
||||
This utility combines apply_rules with logging, providing a summary of
|
||||
all cards affected across multiple rules.
|
||||
|
||||
Args:
|
||||
df: DataFrame to modify
|
||||
rules: List of rule dicts (each with 'mask' and 'tags')
|
||||
summary_message: Overall description (e.g., "card draw effects")
|
||||
color: Color identifier for context (optional)
|
||||
logger: Logger instance to use (optional)
|
||||
|
||||
Returns:
|
||||
Total count of unique cards affected by any rule
|
||||
|
||||
Example:
|
||||
>>> rules = [
|
||||
... {'mask': flying_mask, 'tags': ['Flying']},
|
||||
... {'mask': haste_mask, 'tags': ['Haste', 'Aggro']}
|
||||
... ]
|
||||
>>> count = tag_with_rules_and_logging(
|
||||
... df, rules, 'evasive creatures', color='red', logger=logger
|
||||
... )
|
||||
"""
|
||||
affected = pd.Series([False] * len(df), index=df.index)
|
||||
for rule in rules:
|
||||
mask = rule.get('mask')
|
||||
if callable(mask):
|
||||
mask = mask(df)
|
||||
if mask is not None and mask.any():
|
||||
tags = rule.get('tags', [])
|
||||
apply_tag_vectorized(df, mask, tags)
|
||||
affected |= mask
|
||||
|
||||
count = affected.sum()
|
||||
color_part = f'{color} ' if color else ''
|
||||
full_message = f'Tagged {count} {color_part}{summary_message}'
|
||||
|
||||
if logger:
|
||||
logger.info(full_message)
|
||||
else:
|
||||
print(full_message)
|
||||
|
||||
return count
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -900,7 +900,7 @@ def ideal_labels() -> Dict[str, str]:
|
|||
'removal': 'Spot Removal',
|
||||
'wipes': 'Board Wipes',
|
||||
'card_advantage': 'Card Advantage',
|
||||
'protection': 'Protection',
|
||||
'protection': 'Protective Effects',
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1911,7 +1911,7 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
|
|||
("removal", "Confirm Removal", "add_removal"),
|
||||
("wipes", "Confirm Board Wipes", "add_board_wipes"),
|
||||
("card_advantage", "Confirm Card Advantage", "add_card_advantage"),
|
||||
("protection", "Confirm Protection", "add_protection"),
|
||||
("protection", "Confirm Protective Effects", "add_protection"),
|
||||
]
|
||||
any_granular = any(callable(getattr(b, rn, None)) for _key, _label, rn in spell_categories)
|
||||
if any_granular:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -98,7 +98,7 @@ services:
|
|||
WEB_AUTO_SETUP: "1" # 1=auto-run setup/tagging when needed
|
||||
WEB_AUTO_REFRESH_DAYS: "7" # Refresh cards.csv if older than N days; 0=never
|
||||
WEB_TAG_PARALLEL: "1" # 1=parallelize tagging
|
||||
WEB_TAG_WORKERS: "8" # Worker count when parallel tagging
|
||||
WEB_TAG_WORKERS: "4" # Worker count when parallel tagging
|
||||
|
||||
# Tagging Refinement Feature Flags
|
||||
TAG_NORMALIZE_KEYWORDS: "1" # 1=normalize keywords & filter specialty mechanics (recommended)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue