mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02: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).
|
||||
|
|
|
@ -440,7 +440,11 @@ async def build_new_submit(
|
|||
multi_count: int | None = Form(None),
|
||||
multi_thrumming: str | None = Form(None),
|
||||
# Must-haves/excludes (optional)
|
||||
include_cards: str = Form(""),
|
||||
exclude_cards: str = Form(""),
|
||||
enforcement_mode: str = Form("warn"),
|
||||
allow_illegal: bool = Form(False),
|
||||
fuzzy_matching: bool = Form(True),
|
||||
) -> HTMLResponse:
|
||||
"""Handle New Deck modal submit and immediately start the build (skip separate review page)."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
|
@ -467,7 +471,11 @@ async def build_new_submit(
|
|||
"combo_count": combo_count,
|
||||
"combo_balance": (combo_balance or "mix"),
|
||||
"prefer_combos": bool(prefer_combos),
|
||||
"include_cards": include_cards or "",
|
||||
"exclude_cards": exclude_cards or "",
|
||||
"enforcement_mode": enforcement_mode or "warn",
|
||||
"allow_illegal": bool(allow_illegal),
|
||||
"fuzzy_matching": bool(fuzzy_matching),
|
||||
}
|
||||
}
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
|
@ -575,37 +583,59 @@ async def build_new_submit(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Process exclude cards (M0.5: Phase 1 - Exclude Only)
|
||||
# Process include/exclude cards (M3: Phase 2 - Full Include/Exclude)
|
||||
try:
|
||||
from deck_builder.include_exclude_utils import parse_card_list_input, IncludeExcludeDiagnostics
|
||||
|
||||
# Clear any old exclude data
|
||||
for k in ["exclude_cards", "exclude_diagnostics"]:
|
||||
# Clear any old include/exclude data
|
||||
for k in ["include_cards", "exclude_cards", "include_exclude_diagnostics", "enforcement_mode", "allow_illegal", "fuzzy_matching"]:
|
||||
if k in sess:
|
||||
del sess[k]
|
||||
|
||||
# Process include cards
|
||||
if include_cards and include_cards.strip():
|
||||
print(f"DEBUG: Raw include_cards input: '{include_cards}'")
|
||||
include_list = parse_card_list_input(include_cards.strip())
|
||||
print(f"DEBUG: Parsed include_list: {include_list}")
|
||||
sess["include_cards"] = include_list
|
||||
else:
|
||||
print(f"DEBUG: include_cards is empty or None: '{include_cards}'")
|
||||
|
||||
# Process exclude cards
|
||||
if exclude_cards and exclude_cards.strip():
|
||||
# Parse the exclude list
|
||||
print(f"DEBUG: Raw exclude_cards input: '{exclude_cards}'")
|
||||
exclude_list = parse_card_list_input(exclude_cards.strip())
|
||||
|
||||
# Store in session for the build engine
|
||||
print(f"DEBUG: Parsed exclude_list: {exclude_list}")
|
||||
sess["exclude_cards"] = exclude_list
|
||||
|
||||
# Create diagnostics (for future status display)
|
||||
else:
|
||||
print(f"DEBUG: exclude_cards is empty or None: '{exclude_cards}'")
|
||||
|
||||
# Store advanced options
|
||||
sess["enforcement_mode"] = enforcement_mode
|
||||
sess["allow_illegal"] = allow_illegal
|
||||
sess["fuzzy_matching"] = fuzzy_matching
|
||||
|
||||
# Create basic diagnostics for status tracking
|
||||
if (include_cards and include_cards.strip()) or (exclude_cards and exclude_cards.strip()):
|
||||
diagnostics = IncludeExcludeDiagnostics(
|
||||
missing_includes=[],
|
||||
ignored_color_identity=[],
|
||||
illegal_dropped=[],
|
||||
illegal_allowed=[],
|
||||
excluded_removed=exclude_list,
|
||||
excluded_removed=sess.get("exclude_cards", []),
|
||||
duplicates_collapsed={},
|
||||
include_added=[],
|
||||
include_over_ideal={},
|
||||
fuzzy_corrections={},
|
||||
confirmation_needed=[],
|
||||
list_size_warnings={"excludes_count": len(exclude_list), "excludes_limit": 15}
|
||||
list_size_warnings={
|
||||
"includes_count": len(sess.get("include_cards", [])),
|
||||
"excludes_count": len(sess.get("exclude_cards", [])),
|
||||
"includes_limit": 10,
|
||||
"excludes_limit": 15
|
||||
}
|
||||
)
|
||||
sess["exclude_diagnostics"] = diagnostics.__dict__
|
||||
sess["include_exclude_diagnostics"] = diagnostics.__dict__
|
||||
except Exception as e:
|
||||
# If exclude parsing fails, log but don't block the build
|
||||
import logging
|
||||
|
@ -2570,9 +2600,18 @@ async def build_permalink(request: Request):
|
|||
"locks": list(sess.get("locks", [])),
|
||||
}
|
||||
|
||||
# Add exclude_cards if feature is enabled and present
|
||||
if ALLOW_MUST_HAVES and sess.get("exclude_cards"):
|
||||
payload["exclude_cards"] = sess.get("exclude_cards")
|
||||
# Add include/exclude cards and advanced options if feature is enabled
|
||||
if ALLOW_MUST_HAVES:
|
||||
if sess.get("include_cards"):
|
||||
payload["include_cards"] = sess.get("include_cards")
|
||||
if sess.get("exclude_cards"):
|
||||
payload["exclude_cards"] = sess.get("exclude_cards")
|
||||
if sess.get("enforcement_mode"):
|
||||
payload["enforcement_mode"] = sess.get("enforcement_mode")
|
||||
if sess.get("allow_illegal") is not None:
|
||||
payload["allow_illegal"] = sess.get("allow_illegal")
|
||||
if sess.get("fuzzy_matching") is not None:
|
||||
payload["fuzzy_matching"] = sess.get("fuzzy_matching")
|
||||
try:
|
||||
import base64
|
||||
import json as _json
|
||||
|
@ -2638,32 +2677,248 @@ async def validate_exclude_cards(
|
|||
exclude_cards: str = Form(default=""),
|
||||
commander: str = Form(default="")
|
||||
):
|
||||
"""Validate exclude cards list and return diagnostics."""
|
||||
"""Legacy exclude cards validation endpoint - redirect to new unified endpoint."""
|
||||
if not ALLOW_MUST_HAVES:
|
||||
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
|
||||
|
||||
# Call new unified endpoint
|
||||
result = await validate_include_exclude_cards(
|
||||
request=request,
|
||||
include_cards="",
|
||||
exclude_cards=exclude_cards,
|
||||
commander=commander,
|
||||
enforcement_mode="warn",
|
||||
allow_illegal=False,
|
||||
fuzzy_matching=True
|
||||
)
|
||||
|
||||
# Transform to legacy format for backward compatibility
|
||||
if hasattr(result, 'body'):
|
||||
import json
|
||||
data = json.loads(result.body)
|
||||
if 'excludes' in data:
|
||||
excludes = data['excludes']
|
||||
return JSONResponse({
|
||||
"count": excludes.get("count", 0),
|
||||
"limit": excludes.get("limit", 15),
|
||||
"over_limit": excludes.get("over_limit", False),
|
||||
"cards": excludes.get("cards", []),
|
||||
"duplicates": excludes.get("duplicates", {}),
|
||||
"warnings": excludes.get("warnings", [])
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/validate/include_exclude")
|
||||
async def validate_include_exclude_cards(
|
||||
request: Request,
|
||||
include_cards: str = Form(default=""),
|
||||
exclude_cards: str = Form(default=""),
|
||||
commander: str = Form(default=""),
|
||||
enforcement_mode: str = Form(default="warn"),
|
||||
allow_illegal: bool = Form(default=False),
|
||||
fuzzy_matching: bool = Form(default=True)
|
||||
):
|
||||
"""Validate include/exclude card lists with comprehensive diagnostics."""
|
||||
if not ALLOW_MUST_HAVES:
|
||||
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
|
||||
|
||||
try:
|
||||
from deck_builder.include_exclude_utils import parse_card_list_input
|
||||
from deck_builder.include_exclude_utils import (
|
||||
parse_card_list_input, collapse_duplicates,
|
||||
fuzzy_match_card_name, MAX_INCLUDES, MAX_EXCLUDES
|
||||
)
|
||||
from deck_builder.builder import DeckBuilder
|
||||
|
||||
# Parse the input
|
||||
card_list = parse_card_list_input(exclude_cards)
|
||||
# Parse inputs
|
||||
include_list = parse_card_list_input(include_cards) if include_cards.strip() else []
|
||||
exclude_list = parse_card_list_input(exclude_cards) if exclude_cards.strip() else []
|
||||
|
||||
# Basic validation
|
||||
total_count = len(card_list)
|
||||
max_excludes = 15
|
||||
# Collapse duplicates
|
||||
include_unique, include_dupes = collapse_duplicates(include_list)
|
||||
exclude_unique, exclude_dupes = collapse_duplicates(exclude_list)
|
||||
|
||||
# For now, just return count and limit info
|
||||
# Future: add fuzzy matching validation, commander color identity checks
|
||||
# Initialize result structure
|
||||
result = {
|
||||
"count": total_count,
|
||||
"limit": max_excludes,
|
||||
"over_limit": total_count > max_excludes,
|
||||
"cards": card_list[:10] if len(card_list) <= 10 else card_list[:7] + ["..."], # Show preview
|
||||
"warnings": []
|
||||
"includes": {
|
||||
"count": len(include_unique),
|
||||
"limit": MAX_INCLUDES,
|
||||
"over_limit": len(include_unique) > MAX_INCLUDES,
|
||||
"duplicates": include_dupes,
|
||||
"cards": include_unique[:10] if len(include_unique) <= 10 else include_unique[:7] + ["..."],
|
||||
"warnings": [],
|
||||
"legal": [],
|
||||
"illegal": [],
|
||||
"color_mismatched": [],
|
||||
"fuzzy_matches": {}
|
||||
},
|
||||
"excludes": {
|
||||
"count": len(exclude_unique),
|
||||
"limit": MAX_EXCLUDES,
|
||||
"over_limit": len(exclude_unique) > MAX_EXCLUDES,
|
||||
"duplicates": exclude_dupes,
|
||||
"cards": exclude_unique[:10] if len(exclude_unique) <= 10 else exclude_unique[:7] + ["..."],
|
||||
"warnings": [],
|
||||
"legal": [],
|
||||
"illegal": [],
|
||||
"fuzzy_matches": {}
|
||||
},
|
||||
"conflicts": [], # Cards that appear in both lists
|
||||
"confirmation_needed": [], # Cards needing fuzzy match confirmation
|
||||
"overall_warnings": []
|
||||
}
|
||||
|
||||
if total_count > max_excludes:
|
||||
result["warnings"].append(f"Too many excludes: {total_count}/{max_excludes}")
|
||||
# Check for conflicts (cards in both lists)
|
||||
conflicts = set(include_unique) & set(exclude_unique)
|
||||
if conflicts:
|
||||
result["conflicts"] = list(conflicts)
|
||||
result["overall_warnings"].append(f"Cards appear in both lists: {', '.join(list(conflicts)[:3])}{'...' if len(conflicts) > 3 else ''}")
|
||||
|
||||
# Size warnings based on actual counts
|
||||
if result["includes"]["over_limit"]:
|
||||
result["includes"]["warnings"].append(f"Too many includes: {len(include_unique)}/{MAX_INCLUDES}")
|
||||
elif len(include_unique) > MAX_INCLUDES * 0.8: # 80% capacity warning
|
||||
result["includes"]["warnings"].append(f"Approaching limit: {len(include_unique)}/{MAX_INCLUDES}")
|
||||
|
||||
if result["excludes"]["over_limit"]:
|
||||
result["excludes"]["warnings"].append(f"Too many excludes: {len(exclude_unique)}/{MAX_EXCLUDES}")
|
||||
elif len(exclude_unique) > MAX_EXCLUDES * 0.8: # 80% capacity warning
|
||||
result["excludes"]["warnings"].append(f"Approaching limit: {len(exclude_unique)}/{MAX_EXCLUDES}")
|
||||
|
||||
# Do fuzzy matching regardless of commander (for basic card validation)
|
||||
if fuzzy_matching and (include_unique or exclude_unique):
|
||||
print(f"DEBUG: Attempting fuzzy matching with {len(include_unique)} includes, {len(exclude_unique)} excludes")
|
||||
try:
|
||||
# Get card names directly from CSV without requiring commander setup
|
||||
import pandas as pd
|
||||
cards_df = pd.read_csv('csv_files/cards.csv')
|
||||
print(f"DEBUG: CSV columns: {list(cards_df.columns)}")
|
||||
|
||||
# Try to find the name column
|
||||
name_column = None
|
||||
for col in ['Name', 'name', 'card_name', 'CardName']:
|
||||
if col in cards_df.columns:
|
||||
name_column = col
|
||||
break
|
||||
|
||||
if name_column is None:
|
||||
raise ValueError(f"Could not find name column. Available columns: {list(cards_df.columns)}")
|
||||
|
||||
available_cards = set(cards_df[name_column].tolist())
|
||||
print(f"DEBUG: Loaded {len(available_cards)} available cards")
|
||||
|
||||
# Validate includes with fuzzy matching
|
||||
for card_name in include_unique:
|
||||
print(f"DEBUG: Testing include card: {card_name}")
|
||||
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||
print(f"DEBUG: Match result - name: {match_result.matched_name}, auto_accepted: {match_result.auto_accepted}, confidence: {match_result.confidence}")
|
||||
|
||||
if match_result.matched_name and match_result.auto_accepted:
|
||||
# Exact or high-confidence match
|
||||
result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
||||
result["includes"]["legal"].append(match_result.matched_name)
|
||||
elif not match_result.auto_accepted and match_result.suggestions:
|
||||
# Needs confirmation - has suggestions but low confidence
|
||||
print(f"DEBUG: Adding confirmation for {card_name}")
|
||||
result["confirmation_needed"].append({
|
||||
"input": card_name,
|
||||
"suggestions": match_result.suggestions,
|
||||
"confidence": match_result.confidence,
|
||||
"type": "include"
|
||||
})
|
||||
else:
|
||||
# No match found at all, add to illegal
|
||||
result["includes"]["illegal"].append(card_name)
|
||||
|
||||
# Validate excludes with fuzzy matching
|
||||
for card_name in exclude_unique:
|
||||
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||
if match_result.matched_name:
|
||||
if match_result.auto_accepted:
|
||||
result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
||||
result["excludes"]["legal"].append(match_result.matched_name)
|
||||
else:
|
||||
# Needs confirmation
|
||||
result["confirmation_needed"].append({
|
||||
"input": card_name,
|
||||
"suggestions": match_result.suggestions,
|
||||
"confidence": match_result.confidence,
|
||||
"type": "exclude"
|
||||
})
|
||||
else:
|
||||
# No match found, add to illegal
|
||||
result["excludes"]["illegal"].append(card_name)
|
||||
|
||||
except Exception as fuzzy_error:
|
||||
print(f"DEBUG: Fuzzy matching error: {str(fuzzy_error)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
result["overall_warnings"].append(f"Fuzzy matching unavailable: {str(fuzzy_error)}")
|
||||
|
||||
# If we have a commander, do advanced validation (color identity, etc.)
|
||||
if commander and commander.strip():
|
||||
try:
|
||||
# Create a temporary builder to get available card names
|
||||
builder = DeckBuilder()
|
||||
builder.setup_dataframes()
|
||||
|
||||
# Get available card names for fuzzy matching
|
||||
available_cards = set(builder._full_cards_df['Name'].tolist())
|
||||
|
||||
# Validate includes with fuzzy matching
|
||||
for card_name in include_unique:
|
||||
if fuzzy_matching:
|
||||
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||
if match_result.matched_name:
|
||||
if match_result.auto_accepted:
|
||||
result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
||||
result["includes"]["legal"].append(match_result.matched_name)
|
||||
else:
|
||||
# Needs confirmation
|
||||
result["confirmation_needed"].append({
|
||||
"input": card_name,
|
||||
"suggestions": match_result.suggestions,
|
||||
"confidence": match_result.confidence,
|
||||
"type": "include"
|
||||
})
|
||||
else:
|
||||
result["includes"]["illegal"].append(card_name)
|
||||
else:
|
||||
# Exact match only
|
||||
if card_name in available_cards:
|
||||
result["includes"]["legal"].append(card_name)
|
||||
else:
|
||||
result["includes"]["illegal"].append(card_name)
|
||||
|
||||
# Validate excludes with fuzzy matching
|
||||
for card_name in exclude_unique:
|
||||
if fuzzy_matching:
|
||||
match_result = fuzzy_match_card_name(card_name, available_cards)
|
||||
if match_result.matched_name:
|
||||
if match_result.auto_accepted:
|
||||
result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name
|
||||
result["excludes"]["legal"].append(match_result.matched_name)
|
||||
else:
|
||||
# Needs confirmation
|
||||
result["confirmation_needed"].append({
|
||||
"input": card_name,
|
||||
"suggestions": match_result.suggestions,
|
||||
"confidence": match_result.confidence,
|
||||
"type": "exclude"
|
||||
})
|
||||
else:
|
||||
result["excludes"]["illegal"].append(card_name)
|
||||
else:
|
||||
# Exact match only
|
||||
if card_name in available_cards:
|
||||
result["excludes"]["legal"].append(card_name)
|
||||
else:
|
||||
result["excludes"]["illegal"].append(card_name)
|
||||
|
||||
except Exception as validation_error:
|
||||
# Advanced validation failed, but return basic validation
|
||||
result["overall_warnings"].append(f"Advanced validation unavailable: {str(validation_error)}")
|
||||
|
||||
return JSONResponse(result)
|
||||
|
||||
|
|
|
@ -83,6 +83,7 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
|
|||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
include_cards=sess.get("include_cards"),
|
||||
exclude_cards=sess.get("exclude_cards"),
|
||||
)
|
||||
if set_on_session:
|
||||
|
|
|
@ -1030,6 +1030,23 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
|
|||
except Exception as e:
|
||||
out(f"Land build failed: {e}")
|
||||
|
||||
# M3: Inject includes after lands, before creatures/spells (matching CLI behavior)
|
||||
try:
|
||||
if hasattr(b, '_inject_includes_after_lands'):
|
||||
print(f"DEBUG WEB: About to inject includes. Include cards: {getattr(b, 'include_cards', [])}")
|
||||
# Use builder's logger if available
|
||||
if hasattr(b, 'logger'):
|
||||
b.logger.info(f"DEBUG WEB: About to inject includes. Include cards: {getattr(b, 'include_cards', [])}")
|
||||
b._inject_includes_after_lands()
|
||||
print(f"DEBUG WEB: Finished injecting includes. Current deck size: {len(getattr(b, 'card_library', {}))}")
|
||||
if hasattr(b, 'logger'):
|
||||
b.logger.info(f"DEBUG WEB: Finished injecting includes. Current deck size: {len(getattr(b, 'card_library', {}))}")
|
||||
except Exception as e:
|
||||
out(f"Include injection failed: {e}")
|
||||
print(f"Include injection failed: {e}")
|
||||
if hasattr(b, 'logger'):
|
||||
b.logger.error(f"Include injection failed: {e}")
|
||||
|
||||
try:
|
||||
if hasattr(b, 'add_creatures_phase'):
|
||||
b.add_creatures_phase()
|
||||
|
@ -1285,6 +1302,10 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
|
|||
fn = getattr(b, f"run_land_step{i}", None)
|
||||
if callable(fn):
|
||||
stages.append({"key": f"land{i}", "label": f"Lands (Step {i})", "runner_name": f"run_land_step{i}"})
|
||||
|
||||
# M3: Include injection stage after lands, before creatures
|
||||
if hasattr(b, '_inject_includes_after_lands') and getattr(b, 'include_cards', None):
|
||||
stages.append({"key": "inject_includes", "label": "Include Cards", "runner_name": "__inject_includes__"})
|
||||
# Creatures split into theme sub-stages for web confirm
|
||||
# AND-mode pre-pass: add cards that match ALL selected themes first
|
||||
try:
|
||||
|
@ -1377,6 +1398,7 @@ def start_build_ctx(
|
|||
prefer_combos: bool | None = None,
|
||||
combo_target_count: int | None = None,
|
||||
combo_balance: str | None = None,
|
||||
include_cards: List[str] | None = None,
|
||||
exclude_cards: List[str] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
logs: List[str] = []
|
||||
|
@ -1451,8 +1473,17 @@ def start_build_ctx(
|
|||
# Apply the same global pool pruning in interactive builds for consistency
|
||||
_global_prune_disallowed_pool(b)
|
||||
|
||||
# Apply exclude cards (M0.5: Phase 1 - Exclude Only)
|
||||
# Apply include/exclude cards (M3: Phase 2 - Full Include/Exclude)
|
||||
try:
|
||||
out(f"DEBUG ORCHESTRATOR: include_cards parameter: {include_cards}")
|
||||
out(f"DEBUG ORCHESTRATOR: exclude_cards parameter: {exclude_cards}")
|
||||
|
||||
if include_cards:
|
||||
b.include_cards = list(include_cards)
|
||||
out(f"Applied include cards: {len(include_cards)} cards")
|
||||
out(f"DEBUG ORCHESTRATOR: Set builder.include_cards to: {b.include_cards}")
|
||||
else:
|
||||
out("DEBUG ORCHESTRATOR: No include cards to apply")
|
||||
if exclude_cards:
|
||||
b.exclude_cards = list(exclude_cards)
|
||||
# The filtering is already applied in setup_dataframes(), but we need
|
||||
|
@ -1460,8 +1491,10 @@ def start_build_ctx(
|
|||
b._combined_cards_df = None # Clear cache to force rebuild
|
||||
b.setup_dataframes() # This will now apply the exclude filtering
|
||||
out(f"Applied exclude filtering for {len(exclude_cards)} patterns")
|
||||
else:
|
||||
out("DEBUG ORCHESTRATOR: No exclude cards to apply")
|
||||
except Exception as e:
|
||||
out(f"Failed to apply exclude cards: {e}")
|
||||
out(f"Failed to apply include/exclude cards: {e}")
|
||||
|
||||
# Thread multi-copy selection onto builder for stage generation/runner
|
||||
try:
|
||||
|
@ -1874,6 +1907,18 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
|||
logs.append("No multi-copy additions (empty selection).")
|
||||
except Exception as e:
|
||||
logs.append(f"Stage '{label}' failed: {e}")
|
||||
elif runner_name == '__inject_includes__':
|
||||
try:
|
||||
if hasattr(b, '_inject_includes_after_lands'):
|
||||
b._inject_includes_after_lands()
|
||||
include_count = len(getattr(b, 'include_cards', []))
|
||||
logs.append(f"Include injection completed: {include_count} cards processed")
|
||||
else:
|
||||
logs.append("Include injection method not available")
|
||||
except Exception as e:
|
||||
logs.append(f"Include injection failed: {e}")
|
||||
if hasattr(b, 'logger'):
|
||||
b.logger.error(f"Include injection failed: {e}")
|
||||
elif runner_name == '__auto_complete_combos__':
|
||||
try:
|
||||
# Load curated combos
|
||||
|
|
|
@ -65,7 +65,7 @@
|
|||
--blue-main: #1565c0; /* balanced blue */
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
html,body{height:100%; overflow-x:hidden; max-width:100vw;}
|
||||
body {
|
||||
font-family: system-ui, Arial, sans-serif;
|
||||
margin: 0;
|
||||
|
@ -74,6 +74,7 @@ body {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
/* Honor HTML hidden attribute across the app */
|
||||
[hidden] { display: none !important; }
|
||||
|
@ -84,7 +85,7 @@ body {
|
|||
.top-banner{ min-height: var(--banner-h); }
|
||||
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; }
|
||||
.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
|
||||
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; }
|
||||
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; }
|
||||
.banner-status.busy{ color:#fbbf24; }
|
||||
.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; }
|
||||
.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; }
|
||||
|
@ -125,7 +126,14 @@ body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .
|
|||
.sidebar{ transform: translateX(-100%); visibility: hidden; }
|
||||
body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; }
|
||||
body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; }
|
||||
.content{ padding: .9rem .8rem; }
|
||||
.content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; }
|
||||
}
|
||||
|
||||
/* Additional mobile spacing for bottom floating controls */
|
||||
@media (max-width: 720px) {
|
||||
.content {
|
||||
padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */
|
||||
}
|
||||
}
|
||||
|
||||
.brand h1{ display:none; }
|
||||
|
@ -290,10 +298,36 @@ small, .muted{ color: var(--muted); }
|
|||
.stage-nav .name { font-size:12px; }
|
||||
|
||||
/* Build controls sticky box tweaks */
|
||||
.build-controls { top: calc(var(--banner-offset, 48px) + 6px); }
|
||||
@media (max-width: 720px){
|
||||
.build-controls {
|
||||
position: sticky;
|
||||
top: calc(var(--banner-offset, 48px) + 6px);
|
||||
z-index: 100;
|
||||
background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92));
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin: 0.5rem 0;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.25);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px){
|
||||
:root { --banner-offset: 56px; }
|
||||
.build-controls { position: sticky; border-radius: 8px; margin-left: 0; margin-right: 0; }
|
||||
.build-controls {
|
||||
position: fixed !important; /* Fixed to viewport instead of sticky */
|
||||
bottom: 0 !important; /* Anchor to bottom of screen */
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
top: auto !important; /* Override top positioning */
|
||||
border-radius: 0 !important; /* Remove border radius for full width */
|
||||
margin: 0 !important; /* Remove margins for full edge-to-edge */
|
||||
padding: 0.5rem !important; /* Reduced padding */
|
||||
box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-bottom: none !important; /* Remove bottom border */
|
||||
background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important;
|
||||
z-index: 1000 !important; /* Higher z-index to ensure it's above content */
|
||||
}
|
||||
}
|
||||
@media (min-width: 721px){
|
||||
:root { --banner-offset: 48px; }
|
||||
|
@ -347,3 +381,128 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
|
|||
|
||||
/* Virtualization wrapper should mirror grid to keep multi-column flow */
|
||||
.virt-wrapper { display: grid; }
|
||||
|
||||
/* Mobile responsive fixes for horizontal scrolling issues */
|
||||
@media (max-width: 768px) {
|
||||
/* Prevent horizontal overflow */
|
||||
html, body {
|
||||
overflow-x: hidden !important;
|
||||
width: 100% !important;
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
|
||||
/* Fix modal layout on mobile */
|
||||
.modal {
|
||||
padding: 10px !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 100% !important;
|
||||
max-width: calc(100vw - 20px) !important;
|
||||
box-sizing: border-box !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
/* Force single column for include/exclude grid */
|
||||
.include-exclude-grid {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 1rem !important;
|
||||
}
|
||||
|
||||
/* Fix basics grid */
|
||||
.basics-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 1rem !important;
|
||||
}
|
||||
|
||||
/* Ensure all inputs and textareas fit properly */
|
||||
.modal input,
|
||||
.modal textarea,
|
||||
.modal select {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
/* Fix chips containers */
|
||||
.modal [id$="_chips_container"] {
|
||||
max-width: 100% !important;
|
||||
overflow-x: hidden !important;
|
||||
word-wrap: break-word !important;
|
||||
}
|
||||
|
||||
/* Ensure fieldsets don't overflow */
|
||||
.modal fieldset {
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
/* Fix any inline styles that might cause overflow */
|
||||
.modal fieldset > div,
|
||||
.modal fieldset > div > div {
|
||||
max-width: 100% !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-content {
|
||||
padding: 12px !important;
|
||||
margin: 5px !important;
|
||||
}
|
||||
|
||||
.modal fieldset {
|
||||
padding: 8px !important;
|
||||
margin: 6px 0 !important;
|
||||
}
|
||||
|
||||
/* Enhanced mobile build controls */
|
||||
.build-controls {
|
||||
flex-direction: column !important;
|
||||
gap: 0.25rem !important; /* Reduced gap */
|
||||
align-items: stretch !important;
|
||||
padding: 0.5rem !important; /* Reduced padding */
|
||||
}
|
||||
|
||||
/* Two-column grid layout for mobile build controls */
|
||||
.build-controls {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr 1fr !important; /* Two equal columns */
|
||||
grid-gap: 0.25rem !important;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.build-controls form {
|
||||
display: contents !important; /* Allow form contents to participate in grid */
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.build-controls button {
|
||||
flex: none !important;
|
||||
padding: 0.4rem 0.5rem !important; /* Much smaller padding */
|
||||
font-size: 12px !important; /* Smaller font */
|
||||
min-height: 36px !important; /* Smaller minimum height */
|
||||
line-height: 1.2 !important;
|
||||
width: 100% !important; /* Full width within grid cell */
|
||||
box-sizing: border-box !important;
|
||||
white-space: nowrap !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
/* Hide non-essential elements on mobile to keep it clean */
|
||||
.build-controls .sep,
|
||||
.build-controls .replace-toggle,
|
||||
.build-controls label[style*="margin-left"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.build-controls .sep {
|
||||
display: none !important; /* Hide separators on mobile */
|
||||
}
|
||||
}
|
||||
|
|
|
@ -178,8 +178,10 @@
|
|||
el.innerHTML = '<strong>Setup/Tagging:</strong> ' + msg + ' <a href="/setup/running" style="margin-left:.5rem;">View progress</a>';
|
||||
el.classList.add('busy');
|
||||
} else if (data && data.phase === 'done') {
|
||||
el.innerHTML = '<span class="muted">Setup complete.</span>';
|
||||
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 3000);
|
||||
// Don't show "Setup complete" message to avoid UI stuttering
|
||||
// Just clear any existing content and remove busy state
|
||||
el.innerHTML = '';
|
||||
el.classList.remove('busy');
|
||||
} else if (data && data.phase === 'error') {
|
||||
el.innerHTML = '<span class="error">Setup error.</span>';
|
||||
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 5000);
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -313,6 +313,9 @@
|
|||
<!-- controls now above -->
|
||||
|
||||
{% if status and status.startswith('Build complete') and summary %}
|
||||
<!-- Include/Exclude Summary Panel (M3: Include/Exclude Summary Panel) -->
|
||||
{% include "partials/include_exclude_summary.html" %}
|
||||
|
||||
{% include "partials/deck_summary.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -37,6 +37,8 @@
|
|||
{% else %}
|
||||
<div class="notice">Build completed{% if commander %} — <strong>{{ commander }}</strong>{% endif %}</div>
|
||||
|
||||
<!-- Include/Exclude Summary Panel (M3: Include/Exclude Summary Panel) -->
|
||||
{% include "partials/include_exclude_summary.html" %}
|
||||
|
||||
{% if summary %}
|
||||
{{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set, combos=combos, synergies=synergies, versions=versions) | safe }}
|
||||
|
|
195
code/web/templates/partials/include_exclude_summary.html
Normal file
195
code/web/templates/partials/include_exclude_summary.html
Normal file
|
@ -0,0 +1,195 @@
|
|||
{% if summary and summary.include_exclude_summary %}
|
||||
{% set ie_summary = summary.include_exclude_summary %}
|
||||
{% set has_data = (ie_summary.include_cards|length > 0) or (ie_summary.exclude_cards|length > 0) or (ie_summary.include_added|length > 0) or (ie_summary.excluded_removed|length > 0) %}
|
||||
|
||||
{% if has_data %}
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Include/Exclude Impact</h5>
|
||||
<div style="margin:.5rem 0;">
|
||||
|
||||
<!-- Include Cards Impact -->
|
||||
{% if ie_summary.include_cards|length > 0 %}
|
||||
<div class="impact-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115; margin-bottom:.75rem;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600; color:#4ade80;">
|
||||
✓ Must Include Cards ({{ ie_summary.include_cards|length }})
|
||||
</div>
|
||||
|
||||
<!-- Successfully added includes -->
|
||||
{% if ie_summary.include_added|length > 0 %}
|
||||
<div style="margin:.25rem 0;">
|
||||
<div class="muted" style="font-size:12px; color:#10b981; margin-bottom:.25rem;">
|
||||
✓ Successfully Included ({{ ie_summary.include_added|length }})
|
||||
</div>
|
||||
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for card in ie_summary.include_added %}
|
||||
<span class="chip" style="background:#dcfce7; color:#166534; border:1px solid #bbf7d0;" data-card-name="{{ card }}">{{ card }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Missing includes -->
|
||||
{% if ie_summary.missing_includes|length > 0 %}
|
||||
<div style="margin:.25rem 0;">
|
||||
<div class="muted" style="font-size:12px; color:#ef4444; margin-bottom:.25rem;">
|
||||
⚠ Could Not Include ({{ ie_summary.missing_includes|length }})
|
||||
</div>
|
||||
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for card in ie_summary.missing_includes %}
|
||||
<span class="chip" style="background:#fee2e2; color:#dc2626; border:1px solid #fecaca;" data-card-name="{{ card }}">{{ card }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Fuzzy corrections for includes -->
|
||||
{% if ie_summary.fuzzy_corrections %}
|
||||
<div style="margin:.25rem 0;">
|
||||
<div class="muted" style="font-size:12px; color:#f59e0b; margin-bottom:.25rem;">
|
||||
⚡ Fuzzy Matched
|
||||
</div>
|
||||
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for original, corrected in ie_summary.fuzzy_corrections.items() %}
|
||||
<span class="chip" style="background:#fef3c7; color:#92400e; border:1px solid #fde68a;" title="Original: {{ original }}">
|
||||
{{ original }} → {{ corrected }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Exclude Cards Impact -->
|
||||
{% if ie_summary.exclude_cards|length > 0 %}
|
||||
<div class="impact-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115; margin-bottom:.75rem;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600; color:#ef4444;">
|
||||
✗ Must Exclude Cards ({{ ie_summary.exclude_cards|length }})
|
||||
</div>
|
||||
|
||||
<!-- Successfully excluded cards -->
|
||||
{% if ie_summary.excluded_removed|length > 0 %}
|
||||
<div style="margin:.25rem 0;">
|
||||
<div class="muted" style="font-size:12px; color:#10b981; margin-bottom:.25rem;">
|
||||
✓ Successfully Excluded ({{ ie_summary.excluded_removed|length }})
|
||||
</div>
|
||||
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for card in ie_summary.excluded_removed %}
|
||||
<span class="chip" style="background:#dcfce7; color:#166534; border:1px solid #bbf7d0;" data-card-name="{{ card }}">{{ card }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Show patterns for reference -->
|
||||
<div style="margin:.25rem 0;">
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.25rem;">
|
||||
Exclude Patterns
|
||||
</div>
|
||||
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for pattern in ie_summary.exclude_cards %}
|
||||
<span class="chip" style="background:#374151; color:#e5e7eb; border:1px solid #4b5563;">{{ pattern }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Validation Issues -->
|
||||
{% set has_issues = (ie_summary.illegal_dropped|length > 0) or (ie_summary.illegal_allowed|length > 0) or (ie_summary.ignored_color_identity|length > 0) or (ie_summary.duplicates_collapsed|length > 0) %}
|
||||
{% if has_issues %}
|
||||
<div class="impact-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600; color:#f59e0b;">
|
||||
⚠ Validation Issues
|
||||
</div>
|
||||
|
||||
<!-- Illegal cards dropped -->
|
||||
{% if ie_summary.illegal_dropped|length > 0 %}
|
||||
<div style="margin:.25rem 0;">
|
||||
<div class="muted" style="font-size:12px; color:#ef4444; margin-bottom:.25rem;">
|
||||
Illegal Cards Dropped ({{ ie_summary.illegal_dropped|length }})
|
||||
</div>
|
||||
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for card in ie_summary.illegal_dropped %}
|
||||
<span class="chip" style="background:#fee2e2; color:#dc2626; border:1px solid #fecaca;" data-card-name="{{ card }}">{{ card }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Illegal cards allowed -->
|
||||
{% if ie_summary.illegal_allowed|length > 0 %}
|
||||
<div style="margin:.25rem 0;">
|
||||
<div class="muted" style="font-size:12px; color:#f59e0b; margin-bottom:.25rem;">
|
||||
Illegal Cards Allowed ({{ ie_summary.illegal_allowed|length }})
|
||||
</div>
|
||||
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for card in ie_summary.illegal_allowed %}
|
||||
<span class="chip" style="background:#fef3c7; color:#92400e; border:1px solid #fde68a;" data-card-name="{{ card }}">{{ card }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Color identity issues -->
|
||||
{% if ie_summary.ignored_color_identity|length > 0 %}
|
||||
<div style="margin:.25rem 0;">
|
||||
<div class="muted" style="font-size:12px; color:#f59e0b; margin-bottom:.25rem;">
|
||||
Color Identity Mismatches ({{ ie_summary.ignored_color_identity|length }})
|
||||
</div>
|
||||
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for card in ie_summary.ignored_color_identity %}
|
||||
<span class="chip" style="background:#fef3c7; color:#92400e; border:1px solid #fde68a;" data-card-name="{{ card }}">{{ card }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Duplicate collapses -->
|
||||
{% if ie_summary.duplicates_collapsed|length > 0 %}
|
||||
<div style="margin:.25rem 0;">
|
||||
<div class="muted" style="font-size:12px; color:#6366f1; margin-bottom:.25rem;">
|
||||
Duplicates Collapsed ({{ ie_summary.duplicates_collapsed|length }} groups)
|
||||
</div>
|
||||
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for card, count in ie_summary.duplicates_collapsed.items() %}
|
||||
<span class="chip" style="background:#e0e7ff; color:#4338ca; border:1px solid #c7d2fe;" data-card-name="{{ card }}">
|
||||
{{ card }} ({{ count }}x)
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mobile responsive styles for include/exclude summary (M3: Mobile Responsive Testing) -->
|
||||
<style>
|
||||
@media (max-width: 768px) {
|
||||
.impact-panel {
|
||||
padding: .5rem !important;
|
||||
}
|
||||
.ie-chips {
|
||||
gap: .25rem !important;
|
||||
}
|
||||
.ie-chips .chip {
|
||||
font-size: 12px !important;
|
||||
padding: 2px 6px !important;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.impact-panel {
|
||||
padding: .4rem !important;
|
||||
}
|
||||
.ie-chips .chip {
|
||||
font-size: 11px !important;
|
||||
padding: 1px 4px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
{% endif %}
|
Loading…
Add table
Add a link
Reference in a new issue