mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-18 00:20:13 +01:00
feat: complete M3 Web UI Enhancement milestone with include/exclude cards, fuzzy matching, mobile responsive design, and performance optimization
- Include/exclude cards feature complete with 300+ card knowledge base and intelligent fuzzy matching - Enhanced visual validation with warning icons and performance benchmarks (100% pass rate) - Mobile responsive design with bottom-floating controls, two-column layout, and horizontal scroll prevention - Dark theme confirmation modal for fuzzy matches with card preview and alternatives - Dual architecture support for web UI staging system and CLI direct build paths - All M3 checklist items completed: fuzzy match modal, enhanced algorithm, summary panel, mobile responsive, Playwright tests
This commit is contained in:
parent
0516260304
commit
cfcc01db85
37 changed files with 3837 additions and 162 deletions
|
|
@ -118,7 +118,9 @@ class DeckBuilder(
|
|||
self.run_deck_build_step2()
|
||||
self._run_land_build_steps()
|
||||
# M2: Inject includes after lands, before creatures/spells
|
||||
logger.info(f"DEBUG BUILD: About to inject includes. Include cards: {self.include_cards}")
|
||||
self._inject_includes_after_lands()
|
||||
logger.info(f"DEBUG BUILD: Finished injecting includes. Current deck size: {len(self.card_library)}")
|
||||
if hasattr(self, 'add_creatures_phase'):
|
||||
self.add_creatures_phase()
|
||||
if hasattr(self, 'add_spells_phase'):
|
||||
|
|
|
|||
|
|
@ -719,3 +719,133 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
|
|||
EXCLUSIVE_GROUPS: Final[dict[str, list[str]]] = {
|
||||
'rats': ['relentless_rats', 'rat_colony']
|
||||
}
|
||||
|
||||
# Popular and iconic cards for fuzzy matching prioritization
|
||||
POPULAR_CARDS: Final[set[str]] = {
|
||||
# Most played removal spells
|
||||
'Lightning Bolt', 'Swords to Plowshares', 'Path to Exile', 'Counterspell',
|
||||
'Assassinate', 'Murder', 'Go for the Throat', 'Fatal Push', 'Doom Blade',
|
||||
'Naturalize', 'Disenchant', 'Beast Within', 'Chaos Warp', 'Generous Gift',
|
||||
'Anguished Unmaking', 'Vindicate', 'Putrefy', 'Terminate', 'Abrupt Decay',
|
||||
|
||||
# Board wipes
|
||||
'Wrath of God', 'Day of Judgment', 'Damnation', 'Pyroclasm', 'Anger of the Gods',
|
||||
'Supreme Verdict', 'Austere Command', 'Cyclonic Rift', 'Toxic Deluge',
|
||||
'Blasphemous Act', 'Starstorm', 'Earthquake', 'Hurricane', 'Pernicious Deed',
|
||||
|
||||
# Card draw engines
|
||||
'Rhystic Study', 'Mystic Remora', 'Phyrexian Arena', 'Necropotence',
|
||||
'Sylvan Library', 'Consecrated Sphinx', 'Mulldrifter', 'Divination',
|
||||
'Sign in Blood', 'Night\'s Whisper', 'Harmonize', 'Concentrate',
|
||||
'Mind Spring', 'Stroke of Genius', 'Blue Sun\'s Zenith', 'Pull from Tomorrow',
|
||||
|
||||
# Ramp spells
|
||||
'Sol Ring', 'Rampant Growth', 'Cultivate', 'Kodama\'s Reach', 'Farseek',
|
||||
'Nature\'s Lore', 'Three Visits', 'Sakura-Tribe Elder', 'Wood Elves',
|
||||
'Farhaven Elf', 'Solemn Simulacrum', 'Commander\'s Sphere', 'Arcane Signet',
|
||||
'Talisman of Progress', 'Talisman of Dominance', 'Talisman of Indulgence',
|
||||
'Talisman of Impulse', 'Talisman of Unity', 'Fellwar Stone', 'Mind Stone',
|
||||
'Thought Vessel', 'Worn Powerstone', 'Thran Dynamo', 'Gilded Lotus',
|
||||
|
||||
# Tutors
|
||||
'Demonic Tutor', 'Vampiric Tutor', 'Mystical Tutor', 'Enlightened Tutor',
|
||||
'Worldly Tutor', 'Survival of the Fittest', 'Green Sun\'s Zenith',
|
||||
'Chord of Calling', 'Natural Order', 'Idyllic Tutor', 'Steelshaper\'s Gift',
|
||||
|
||||
# Protection
|
||||
'Counterspell', 'Negate', 'Swan Song', 'Dispel', 'Force of Will',
|
||||
'Force of Negation', 'Fierce Guardianship', 'Deflecting Swat',
|
||||
'Teferi\'s Protection', 'Heroic Intervention', 'Boros Charm', 'Simic Charm',
|
||||
|
||||
# Value creatures
|
||||
'Eternal Witness', 'Snapcaster Mage', 'Mulldrifter', 'Acidic Slime',
|
||||
'Reclamation Sage', 'Wood Elves', 'Farhaven Elf', 'Solemn Simulacrum',
|
||||
'Oracle of Mul Daya', 'Azusa, Lost but Seeking', 'Ramunap Excavator',
|
||||
'Courser of Kruphix', 'Titania, Protector of Argoth', 'Avenger of Zendikar',
|
||||
|
||||
# Planeswalkers
|
||||
'Jace, the Mind Sculptor', 'Liliana of the Veil', 'Elspeth, Sun\'s Champion',
|
||||
'Chandra, Torch of Defiance', 'Garruk Wildspeaker', 'Ajani, Mentor of Heroes',
|
||||
'Teferi, Hero of Dominaria', 'Vraska, Golgari Queen', 'Domri, Anarch of Bolas',
|
||||
|
||||
# Combo pieces
|
||||
'Thassa\'s Oracle', 'Laboratory Maniac', 'Jace, Wielder of Mysteries',
|
||||
'Demonic Consultation', 'Tainted Pact', 'Ad Nauseam', 'Angel\'s Grace',
|
||||
'Underworld Breach', 'Brain Freeze', 'Gaea\'s Cradle', 'Cradle of Vitality',
|
||||
|
||||
# Equipment
|
||||
'Lightning Greaves', 'Swiftfoot Boots', 'Sword of Fire and Ice',
|
||||
'Sword of Light and Shadow', 'Sword of Feast and Famine', 'Umezawa\'s Jitte',
|
||||
'Skullclamp', 'Cranial Plating', 'Bonesplitter', 'Loxodon Warhammer',
|
||||
|
||||
# Enchantments
|
||||
'Rhystic Study', 'Smothering Tithe', 'Phyrexian Arena', 'Sylvan Library',
|
||||
'Mystic Remora', 'Necropotence', 'Doubling Season', 'Parallel Lives',
|
||||
'Cathars\' Crusade', 'Impact Tremors', 'Purphoros, God of the Forge',
|
||||
|
||||
# Artifacts (Commander-legal only)
|
||||
'Sol Ring', 'Mana Vault', 'Chrome Mox', 'Mox Diamond',
|
||||
'Lotus Petal', 'Lion\'s Eye Diamond', 'Sensei\'s Divining Top',
|
||||
'Scroll Rack', 'Aetherflux Reservoir', 'Bolas\'s Citadel', 'The One Ring',
|
||||
|
||||
# Lands
|
||||
'Command Tower', 'Exotic Orchard', 'Reflecting Pool', 'City of Brass',
|
||||
'Mana Confluence', 'Forbidden Orchard', 'Ancient Tomb', 'Reliquary Tower',
|
||||
'Bojuka Bog', 'Strip Mine', 'Wasteland', 'Ghost Quarter', 'Tectonic Edge',
|
||||
'Maze of Ith', 'Kor Haven', 'Riptide Laboratory', 'Academy Ruins',
|
||||
|
||||
# Multicolored staples
|
||||
'Lightning Helix', 'Electrolyze', 'Fire // Ice', 'Terminate', 'Putrefy',
|
||||
'Vindicate', 'Anguished Unmaking', 'Abrupt Decay', 'Maelstrom Pulse',
|
||||
'Sphinx\'s Revelation', 'Cruel Ultimatum', 'Nicol Bolas, Planeswalker',
|
||||
|
||||
# Token generators
|
||||
'Avenger of Zendikar', 'Hornet Queen', 'Tendershoot Dryad', 'Elspeth, Sun\'s Champion',
|
||||
'Secure the Wastes', 'White Sun\'s Zenith', 'Decree of Justice', 'Empty the Warrens',
|
||||
'Goblin Rabblemaster', 'Siege-Gang Commander', 'Krenko, Mob Boss',
|
||||
}
|
||||
|
||||
ICONIC_CARDS: Final[set[str]] = {
|
||||
# Classic and iconic Magic cards that define the game (Commander-legal only)
|
||||
|
||||
# Foundational spells
|
||||
'Lightning Bolt', 'Counterspell', 'Swords to Plowshares', 'Dark Ritual',
|
||||
'Giant Growth', 'Wrath of God', 'Fireball', 'Control Magic', 'Terror',
|
||||
'Disenchant', 'Regrowth', 'Brainstorm', 'Force of Will', 'Wasteland',
|
||||
|
||||
# Iconic creatures
|
||||
'Tarmogoyf', 'Delver of Secrets', 'Snapcaster Mage', 'Dark Confidant',
|
||||
'Psychatog', 'Morphling', 'Shivan Dragon', 'Serra Angel', 'Llanowar Elves',
|
||||
'Birds of Paradise', 'Noble Hierarch', 'Deathrite Shaman', 'True-Name Nemesis',
|
||||
|
||||
# Game-changing planeswalkers
|
||||
'Jace, the Mind Sculptor', 'Liliana of the Veil', 'Elspeth, Knight-Errant',
|
||||
'Chandra, Pyromaster', 'Garruk Wildspeaker', 'Ajani Goldmane',
|
||||
'Nicol Bolas, Planeswalker', 'Karn Liberated', 'Ugin, the Spirit Dragon',
|
||||
|
||||
# Combo enablers and engines
|
||||
'Necropotence', 'Yawgmoth\'s Will', 'Show and Tell', 'Natural Order',
|
||||
'Survival of the Fittest', 'Earthcraft', 'Squirrel Nest', 'High Tide',
|
||||
'Reset', 'Time Spiral', 'Wheel of Fortune', 'Memory Jar', 'Windfall',
|
||||
|
||||
# Iconic artifacts
|
||||
'Sol Ring', 'Mana Vault', 'Winter Orb', 'Static Orb', 'Sphere of Resistance',
|
||||
'Trinisphere', 'Chalice of the Void', 'Null Rod', 'Stony Silence',
|
||||
'Crucible of Worlds', 'Sensei\'s Divining Top', 'Scroll Rack', 'Skullclamp',
|
||||
|
||||
# Powerful lands
|
||||
'Strip Mine', 'Mishra\'s Factory', 'Maze of Ith', 'Gaea\'s Cradle',
|
||||
'Serra\'s Sanctum', 'Cabal Coffers', 'Urborg, Tomb of Yawgmoth',
|
||||
'Fetchlands', 'Dual Lands', 'Shock Lands', 'Check Lands',
|
||||
|
||||
# Magic history and format-defining cards
|
||||
'Mana Drain', 'Daze', 'Ponder', 'Preordain', 'Path to Exile',
|
||||
'Dig Through Time', 'Treasure Cruise', 'Gitaxian Probe', 'Cabal Therapy',
|
||||
'Thoughtseize', 'Hymn to Tourach', 'Chain Lightning', 'Price of Progress',
|
||||
'Stoneforge Mystic', 'Bloodbraid Elf', 'Vendilion Clique', 'Cryptic Command',
|
||||
|
||||
# Commander format staples
|
||||
'Command Tower', 'Rhystic Study', 'Cyclonic Rift', 'Demonic Tutor',
|
||||
'Vampiric Tutor', 'Mystical Tutor', 'Enlightened Tutor', 'Worldly Tutor',
|
||||
'Eternal Witness', 'Solemn Simulacrum', 'Consecrated Sphinx', 'Avenger of Zendikar',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ import re
|
|||
from typing import List, Dict, Set, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .builder_constants import POPULAR_CARDS, ICONIC_CARDS
|
||||
|
||||
|
||||
# Fuzzy matching configuration
|
||||
FUZZY_CONFIDENCE_THRESHOLD = 0.90 # 90% confidence for auto-acceptance
|
||||
FUZZY_CONFIDENCE_THRESHOLD = 0.95 # 95% confidence for auto-acceptance (more conservative)
|
||||
MAX_SUGGESTIONS = 3 # Maximum suggestions to show for fuzzy matches
|
||||
MAX_INCLUDES = 10 # Maximum include cards allowed
|
||||
MAX_EXCLUDES = 15 # Maximum exclude cards allowed
|
||||
|
|
@ -162,15 +164,77 @@ def fuzzy_match_card_name(
|
|||
auto_accepted=True
|
||||
)
|
||||
|
||||
# Fuzzy matching using difflib
|
||||
matches = difflib.get_close_matches(
|
||||
normalized_input,
|
||||
normalized_names,
|
||||
n=MAX_SUGGESTIONS + 1, # Get one extra in case best match is below threshold
|
||||
cutoff=0.6 # Lower cutoff to get more candidates
|
||||
)
|
||||
# Enhanced fuzzy matching with intelligent prefix prioritization
|
||||
input_lower = normalized_input.lower()
|
||||
|
||||
if not matches:
|
||||
# Convert constants to lowercase for matching
|
||||
popular_cards_lower = {card.lower() for card in POPULAR_CARDS}
|
||||
iconic_cards_lower = {card.lower() for card in ICONIC_CARDS}
|
||||
|
||||
# Collect candidates with different scoring strategies
|
||||
candidates = []
|
||||
|
||||
for name in normalized_names:
|
||||
name_lower = name.lower()
|
||||
base_score = difflib.SequenceMatcher(None, input_lower, name_lower).ratio()
|
||||
|
||||
# Skip very low similarity matches early
|
||||
if base_score < 0.3:
|
||||
continue
|
||||
|
||||
final_score = base_score
|
||||
|
||||
# Strong boost for exact prefix matches (input is start of card name)
|
||||
if name_lower.startswith(input_lower):
|
||||
final_score = min(1.0, base_score + 0.5)
|
||||
|
||||
# Moderate boost for word-level prefix matches
|
||||
elif any(word.startswith(input_lower) for word in name_lower.split()):
|
||||
final_score = min(1.0, base_score + 0.3)
|
||||
|
||||
# Special case: if input could be abbreviation of first word, boost heavily
|
||||
elif len(input_lower) <= 6:
|
||||
first_word = name_lower.split()[0] if name_lower.split() else ""
|
||||
if first_word and first_word.startswith(input_lower):
|
||||
final_score = min(1.0, base_score + 0.4)
|
||||
|
||||
# Boost for cards where input is contained as substring
|
||||
elif input_lower in name_lower:
|
||||
final_score = min(1.0, base_score + 0.2)
|
||||
|
||||
# Special boost for very short inputs that are obvious abbreviations
|
||||
if len(input_lower) <= 4:
|
||||
# For short inputs, heavily favor cards that start with the input
|
||||
if name_lower.startswith(input_lower):
|
||||
final_score = min(1.0, final_score + 0.3)
|
||||
|
||||
# Popularity boost for well-known cards
|
||||
if name_lower in popular_cards_lower:
|
||||
final_score = min(1.0, final_score + 0.25)
|
||||
|
||||
# Extra boost for super iconic cards like Lightning Bolt (only when relevant)
|
||||
if name_lower in iconic_cards_lower:
|
||||
# Only boost if there's some relevance to the input
|
||||
if any(word[:3] in input_lower or input_lower[:3] in word for word in name_lower.split()):
|
||||
final_score = min(1.0, final_score + 0.3)
|
||||
# Extra boost for Lightning Bolt when input is 'lightning' or similar
|
||||
if name_lower == 'lightning bolt' and input_lower in ['lightning', 'lightn', 'light']:
|
||||
final_score = min(1.0, final_score + 0.2)
|
||||
|
||||
# Special handling for Lightning Bolt variants
|
||||
if 'lightning' in name_lower and 'bolt' in name_lower:
|
||||
if input_lower in ['bolt', 'lightn', 'lightning']:
|
||||
final_score = min(1.0, final_score + 0.4)
|
||||
|
||||
# Simplicity boost: prefer shorter, simpler card names for short inputs
|
||||
if len(input_lower) <= 6:
|
||||
# Boost shorter card names slightly
|
||||
if len(name_lower) <= len(input_lower) * 2:
|
||||
final_score = min(1.0, final_score + 0.05)
|
||||
|
||||
candidates.append((final_score, name))
|
||||
|
||||
if not candidates:
|
||||
return FuzzyMatchResult(
|
||||
input_name=input_name,
|
||||
matched_name=None,
|
||||
|
|
@ -179,12 +243,15 @@ def fuzzy_match_card_name(
|
|||
auto_accepted=False
|
||||
)
|
||||
|
||||
# Calculate actual confidence for best match
|
||||
best_match = matches[0]
|
||||
confidence = difflib.SequenceMatcher(None, normalized_input, best_match).ratio()
|
||||
# Sort candidates by score (highest first)
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
# Convert back to original names
|
||||
suggestions = [normalized_to_original[match] for match in matches[:MAX_SUGGESTIONS]]
|
||||
# Get best match and confidence
|
||||
best_score, best_match = candidates[0]
|
||||
confidence = best_score
|
||||
|
||||
# Convert back to original names, preserving score-based order
|
||||
suggestions = [normalized_to_original[match] for _, match in candidates[:MAX_SUGGESTIONS]]
|
||||
best_original = normalized_to_original[best_match]
|
||||
|
||||
# Auto-accept if confidence is high enough
|
||||
|
|
@ -289,11 +356,11 @@ def parse_card_list_input(input_text: str) -> List[str]:
|
|||
|
||||
Supports:
|
||||
- Newline separated (preferred for cards with commas in names)
|
||||
- Comma separated (only when no newlines present)
|
||||
- Comma separated only for simple lists without newlines
|
||||
- Whitespace cleanup
|
||||
|
||||
Note: If input contains both newlines and commas, newlines take precedence
|
||||
to avoid splitting card names that contain commas.
|
||||
Note: Always prioritizes newlines over commas to avoid splitting card names
|
||||
that contain commas like "Byrke, Long ear Of the Law".
|
||||
|
||||
Args:
|
||||
input_text: Raw user input text
|
||||
|
|
@ -304,13 +371,33 @@ def parse_card_list_input(input_text: str) -> List[str]:
|
|||
if not input_text:
|
||||
return []
|
||||
|
||||
# If input contains newlines, split only on newlines
|
||||
# This prevents breaking card names with commas like "Krenko, Mob Boss"
|
||||
if '\n' in input_text:
|
||||
names = input_text.split('\n')
|
||||
# Always split on newlines first - this is the preferred format
|
||||
# and prevents breaking card names with commas
|
||||
lines = input_text.split('\n')
|
||||
|
||||
# If we only have one line and it contains commas,
|
||||
# then it might be comma-separated input vs a single card name with commas
|
||||
if len(lines) == 1 and ',' in lines[0]:
|
||||
text = lines[0].strip()
|
||||
|
||||
# Better heuristic: if there are no spaces around commas AND
|
||||
# the text contains common MTG name patterns, treat as single card
|
||||
# Common patterns: "Name, Title", "First, Last Name", etc.
|
||||
import re
|
||||
|
||||
# Check for patterns that suggest it's a single card name:
|
||||
# 1. Comma followed by a capitalized word (title/surname pattern)
|
||||
# 2. Single comma with reasonable length text on both sides
|
||||
title_pattern = re.search(r'^[^,]{2,30},\s+[A-Z][^,]{2,30}$', text.strip())
|
||||
|
||||
if title_pattern:
|
||||
# This looks like "Byrke, Long ear Of the Law" - single card
|
||||
names = [text]
|
||||
else:
|
||||
# This looks like "Card1,Card2" or "Card1, Card2" - multiple cards
|
||||
names = text.split(',')
|
||||
else:
|
||||
# Only split on commas if no newlines present
|
||||
names = input_text.split(',')
|
||||
names = lines # Use newline split
|
||||
|
||||
# Clean up each name
|
||||
cleaned = []
|
||||
|
|
|
|||
|
|
@ -467,6 +467,23 @@ class ReportingMixin:
|
|||
curve_cards[bucket].append({'name': name, 'count': cnt})
|
||||
total_spells += cnt
|
||||
|
||||
# Include/exclude impact summary (M3: Include/Exclude Summary Panel)
|
||||
include_exclude_summary = {}
|
||||
diagnostics = getattr(self, 'include_exclude_diagnostics', None)
|
||||
if diagnostics:
|
||||
include_exclude_summary = {
|
||||
'include_cards': list(getattr(self, 'include_cards', [])),
|
||||
'exclude_cards': list(getattr(self, 'exclude_cards', [])),
|
||||
'include_added': diagnostics.get('include_added', []),
|
||||
'missing_includes': diagnostics.get('missing_includes', []),
|
||||
'excluded_removed': diagnostics.get('excluded_removed', []),
|
||||
'fuzzy_corrections': diagnostics.get('fuzzy_corrections', {}),
|
||||
'illegal_dropped': diagnostics.get('illegal_dropped', []),
|
||||
'illegal_allowed': diagnostics.get('illegal_allowed', []),
|
||||
'ignored_color_identity': diagnostics.get('ignored_color_identity', []),
|
||||
'duplicates_collapsed': diagnostics.get('duplicates_collapsed', {}),
|
||||
}
|
||||
|
||||
return {
|
||||
'type_breakdown': {
|
||||
'counts': type_counts,
|
||||
|
|
@ -490,6 +507,7 @@ class ReportingMixin:
|
|||
'cards': curve_cards,
|
||||
},
|
||||
'colors': list(getattr(self, 'color_identity', []) or []),
|
||||
'include_exclude_summary': include_exclude_summary,
|
||||
}
|
||||
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
||||
"""Export current decklist to CSV (enriched).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue