Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
from __future__ import annotations
2025-10-14 16:09:58 -07:00
from fastapi import APIRouter , Request , Form , Query , BackgroundTasks
2025-08-28 14:57:22 -07:00
from fastapi . responses import HTMLResponse , JSONResponse
2025-10-14 16:09:58 -07:00
from typing import Any , Dict , Iterable
2025-10-07 15:56:57 -07:00
import json
2025-10-03 10:43:24 -07:00
from . . app import (
ALLOW_MUST_HAVES ,
ENABLE_CUSTOM_THEMES ,
2025-10-07 15:56:57 -07:00
SHOW_MUST_HAVE_BUTTONS ,
2025-10-03 10:43:24 -07:00
USER_THEME_LIMIT ,
DEFAULT_THEME_MATCH_MODE ,
_sanitize_theme ,
2025-10-06 09:17:59 -07:00
ENABLE_PARTNER_MECHANICS ,
ENABLE_PARTNER_SUGGESTIONS ,
2025-10-14 16:45:49 -07:00
WEB_IDEALS_UI ,
2025-10-20 18:29:53 -07:00
ENABLE_BATCH_BUILD ,
2025-10-03 10:43:24 -07:00
)
2025-09-02 11:39:14 -07:00
from . . services . build_utils import (
2025-10-07 15:56:57 -07:00
step5_base_ctx ,
2025-09-02 11:39:14 -07:00
step5_ctx_from_result ,
step5_error_ctx ,
step5_empty_ctx ,
start_ctx_from_session ,
owned_set as owned_set_helper ,
builder_present_names ,
builder_display_map ,
2025-10-28 08:21:52 -07:00
commander_hover_context ,
2025-09-02 11:39:14 -07:00
)
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
from . . app import templates
from deck_builder import builder_constants as bc
from . . services import orchestrator as orch
2025-09-02 11:39:14 -07:00
from . . services . orchestrator import is_setup_ready as _is_setup_ready , is_setup_stale as _is_setup_stale # type: ignore
from . . services . build_utils import owned_names as owned_names_helper
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
from . . services . tasks import get_session , new_sid
2025-08-28 14:57:22 -07:00
from html import escape as _esc
2025-08-29 09:19:03 -07:00
from deck_builder . builder import DeckBuilder
from deck_builder import builder_utils as bu
2025-09-02 11:39:14 -07:00
from . . services . combo_utils import detect_all as _detect_all
2025-10-03 10:43:24 -07:00
from . . services import custom_theme_manager as theme_mgr
2025-09-17 13:23:27 -07:00
from path_util import csv_dir as _csv_dir
2025-09-02 11:39:14 -07:00
from . . services . alts_utils import get_cached as _alts_get_cached , set_cached as _alts_set_cached
2025-10-06 09:17:59 -07:00
from . . services . telemetry import (
log_commander_create_deck ,
log_partner_suggestion_selected ,
2025-10-07 15:56:57 -07:00
log_include_exclude_toggle ,
2025-10-06 09:17:59 -07:00
)
from . . services . partner_suggestions import get_partner_suggestions
from urllib . parse import urlparse , quote_plus
2025-10-02 15:31:05 -07:00
from commander_exclusions import lookup_commander_detail
2025-10-06 09:17:59 -07:00
from . . services . commander_catalog_loader import (
load_commander_catalog ,
find_commander_record ,
CommanderRecord ,
normalized_restricted_labels ,
shared_restricted_partner_label ,
)
from deck_builder . background_loader import load_background_cards
from deck_builder . partner_selection import apply_partner_inputs
from exceptions import CommanderPartnerError
from code . logging_util import get_logger
LOGGER = get_logger ( __name__ )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
2025-09-12 10:50:57 -07:00
# Cache for available card names used by validation endpoints
_AVAILABLE_CARDS_CACHE : set [ str ] | None = None
_AVAILABLE_CARDS_NORM_SET : set [ str ] | None = None
_AVAILABLE_CARDS_NORM_MAP : dict [ str , str ] | None = None
def _available_cards ( ) - > set [ str ] :
""" Fast load of available card names using the csv module (no pandas).
Reads only once and caches results in memory .
"""
global _AVAILABLE_CARDS_CACHE
if _AVAILABLE_CARDS_CACHE is not None :
return _AVAILABLE_CARDS_CACHE
try :
import csv
2025-09-17 13:23:27 -07:00
path = f " { _csv_dir ( ) } /cards.csv "
2025-09-12 10:50:57 -07:00
with open ( path , ' r ' , encoding = ' utf-8 ' , newline = ' ' ) as f :
reader = csv . DictReader ( f )
fields = reader . fieldnames or [ ]
name_col = None
for col in [ ' name ' , ' Name ' , ' card_name ' , ' CardName ' ] :
if col in fields :
name_col = col
break
if name_col is None and fields :
# Heuristic: pick first field containing 'name'
for col in fields :
if ' name ' in col . lower ( ) :
name_col = col
break
if name_col is None :
raise ValueError ( f " No name-like column found in { path } : { fields } " )
names : set [ str ] = set ( )
for row in reader :
try :
v = row . get ( name_col )
if v :
names . add ( str ( v ) )
except Exception :
continue
_AVAILABLE_CARDS_CACHE = names
return _AVAILABLE_CARDS_CACHE
except Exception :
_AVAILABLE_CARDS_CACHE = set ( )
return _AVAILABLE_CARDS_CACHE
def _available_cards_normalized ( ) - > tuple [ set [ str ] , dict [ str , str ] ] :
""" Return cached normalized card names and mapping to originals. """
global _AVAILABLE_CARDS_NORM_SET , _AVAILABLE_CARDS_NORM_MAP
if _AVAILABLE_CARDS_NORM_SET is not None and _AVAILABLE_CARDS_NORM_MAP is not None :
return _AVAILABLE_CARDS_NORM_SET , _AVAILABLE_CARDS_NORM_MAP
# Build from available cards set
names = _available_cards ( )
try :
from deck_builder . include_exclude_utils import normalize_punctuation
except Exception :
# Fallback: identity normalization
def normalize_punctuation ( x : str ) - > str : # type: ignore
return str ( x ) . strip ( ) . casefold ( )
norm_map : dict [ str , str ] = { }
for name in names :
try :
n = normalize_punctuation ( name )
if n not in norm_map :
norm_map [ n ] = name
except Exception :
continue
_AVAILABLE_CARDS_NORM_MAP = norm_map
_AVAILABLE_CARDS_NORM_SET = set ( norm_map . keys ( ) )
return _AVAILABLE_CARDS_NORM_SET , _AVAILABLE_CARDS_NORM_MAP
def warm_validation_name_cache ( ) - > None :
""" Pre-populate the available-cards caches to avoid first-call latency. """
try :
_ = _available_cards ( )
_ = _available_cards_normalized ( )
except Exception :
# Best-effort warmup; proceed silently on failure
pass
2025-10-06 09:17:59 -07:00
2025-10-08 11:38:30 -07:00
def _merge_hx_trigger ( response : Any , payload : dict [ str , Any ] ) - > None :
if not payload or response is None :
return
try :
existing = response . headers . get ( " HX-Trigger " ) if hasattr ( response , " headers " ) else None
except Exception :
existing = None
try :
if existing :
try :
data = json . loads ( existing )
except Exception :
data = { }
if isinstance ( data , dict ) :
data . update ( payload )
response . headers [ " HX-Trigger " ] = json . dumps ( data )
return
response . headers [ " HX-Trigger " ] = json . dumps ( payload )
except Exception :
try :
response . headers [ " HX-Trigger " ] = json . dumps ( payload )
except Exception :
pass
def _step5_summary_placeholder_html ( token : int , * , message : str | None = None ) - > str :
text = message or " Deck summary will appear after the build completes. "
return (
f ' <div id= " deck-summary " data-summary '
f ' hx-get= " /build/step5/summary?token= { token } " '
2025-10-08 20:59:51 -07:00
' hx-trigger= " step5:refresh from:body " hx-swap= " outerHTML " > '
2025-10-08 11:38:30 -07:00
f ' <div class= " muted " style= " margin-top:1rem; " > { _esc ( text ) } </div> '
' </div> '
)
def _must_have_state ( sess : dict ) - > tuple [ dict [ str , Any ] , list [ str ] , list [ str ] ] :
includes = list ( sess . get ( " include_cards " ) or [ ] )
excludes = list ( sess . get ( " exclude_cards " ) or [ ] )
state = {
" includes " : includes ,
" excludes " : excludes ,
" enforcement_mode " : ( sess . get ( " enforcement_mode " ) or " warn " ) ,
" allow_illegal " : bool ( sess . get ( " allow_illegal " ) ) ,
" fuzzy_matching " : bool ( sess . get ( " fuzzy_matching " , True ) ) ,
}
return state , includes , excludes
def _render_include_exclude_summary (
request : Request ,
sess : dict ,
sid : str ,
* ,
state : dict [ str , Any ] | None = None ,
includes : list [ str ] | None = None ,
excludes : list [ str ] | None = None ,
) - > HTMLResponse :
ctx = step5_base_ctx ( request , sess , include_name = False , include_locks = False )
if state is None or includes is None or excludes is None :
state , includes , excludes = _must_have_state ( sess )
ctx [ " must_have_state " ] = state
ctx [ " summary " ] = sess . get ( " step5_summary " ) if sess . get ( " step5_summary_ready " ) else None
ctx [ " include_cards " ] = includes
ctx [ " exclude_cards " ] = excludes
response = templates . TemplateResponse ( " partials/include_exclude_summary.html " , ctx )
response . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return response
def _current_builder_summary ( sess : dict ) - > Any | None :
try :
ctx = sess . get ( " build_ctx " ) or { }
builder = ctx . get ( " builder " ) if isinstance ( ctx , dict ) else None
if builder is None :
return None
summary_fn = getattr ( builder , " build_deck_summary " , None )
if callable ( summary_fn ) :
return summary_fn ( )
except Exception :
return None
return None
2025-10-06 09:17:59 -07:00
_COLOR_NAME_MAP = {
" W " : " White " ,
" U " : " Blue " ,
" B " : " Black " ,
" R " : " Red " ,
" G " : " Green " ,
" C " : " Colorless " ,
}
_WUBRG_ORDER = ( " W " , " U " , " B " , " R " , " G " , " C " )
_PARTNER_MODE_LABELS = {
" partner " : " Partner " ,
" partner_restricted " : " Partner (Restricted) " ,
" partner_with " : " Partner With " ,
" background " : " Choose a Background " ,
" doctor_companion " : " Doctor & Companion " ,
}
def _color_code ( identity : Iterable [ str ] ) - > str :
colors = [ str ( c ) . strip ( ) . upper ( ) for c in identity if str ( c ) . strip ( ) ]
if not colors :
return " C "
ordered : list [ str ] = [ c for c in _WUBRG_ORDER if c in colors ]
for color in colors :
if color not in ordered :
ordered . append ( color )
return " " . join ( ordered ) or " C "
def _format_color_label ( identity : Iterable [ str ] ) - > str :
code = _color_code ( identity )
if code == " C " :
return " Colorless (C) "
names = [ _COLOR_NAME_MAP . get ( ch , ch ) for ch in code ]
return " / " . join ( names ) + f " ( { code } ) "
def _partner_mode_label ( mode : str | None ) - > str :
if not mode :
return " Partner Mechanics "
return _PARTNER_MODE_LABELS . get ( mode , mode . title ( ) )
def _scryfall_image_url ( card_name : str , version : str = " normal " ) - > str | None :
name = str ( card_name or " " ) . strip ( )
if not name :
return None
return f " https://api.scryfall.com/cards/named?fuzzy= { quote_plus ( name ) } &format=image&version= { version } "
def _scryfall_page_url ( card_name : str ) - > str | None :
name = str ( card_name or " " ) . strip ( )
if not name :
return None
return f " https://scryfall.com/search?q= { quote_plus ( name ) } "
def _secondary_role_label ( mode : str | None , secondary_name : str | None ) - > str | None :
if not mode :
return None
mode_lower = mode . lower ( )
if mode_lower == " background " :
return " Background "
if mode_lower == " partner_with " :
return " Partner With "
if mode_lower == " doctor_companion " :
record = find_commander_record ( secondary_name or " " ) if secondary_name else None
if record and getattr ( record , " is_doctor " , False ) :
return " Doctor "
if record and getattr ( record , " is_doctors_companion " , False ) :
return " Doctor ' s Companion "
return " Doctor pairing "
return " Partner commander "
def _combined_to_payload ( combined : Any ) - > dict [ str , Any ] :
color_identity = tuple ( getattr ( combined , " color_identity " , ( ) ) or ( ) )
warnings = list ( getattr ( combined , " warnings " , [ ] ) or [ ] )
mode_obj = getattr ( combined , " partner_mode " , None )
mode_value = getattr ( mode_obj , " value " , None ) if mode_obj is not None else None
secondary = getattr ( combined , " secondary_name " , None )
secondary_image = _scryfall_image_url ( secondary )
secondary_url = _scryfall_page_url ( secondary )
secondary_role = _secondary_role_label ( mode_value , secondary )
return {
" primary_name " : getattr ( combined , " primary_name " , None ) ,
" secondary_name " : secondary ,
" partner_mode " : mode_value ,
" partner_mode_label " : _partner_mode_label ( mode_value ) ,
" color_identity " : list ( color_identity ) ,
" color_code " : _color_code ( color_identity ) ,
" color_label " : _format_color_label ( color_identity ) ,
" theme_tags " : list ( getattr ( combined , " theme_tags " , [ ] ) or [ ] ) ,
" warnings " : warnings ,
" secondary_image_url " : secondary_image ,
" secondary_scryfall_url " : secondary_url ,
" secondary_role_label " : secondary_role ,
}
def _build_partner_options ( primary : CommanderRecord | None ) - > tuple [ list [ dict [ str , Any ] ] , str | None ] :
if not ENABLE_PARTNER_MECHANICS :
return [ ] , None
try :
catalog = load_commander_catalog ( )
except Exception :
return [ ] , None
if primary is None :
return [ ] , None
primary_name = primary . display_name . casefold ( )
primary_partner_targets = { target . casefold ( ) for target in ( primary . partner_with or ( ) ) }
primary_is_partner = bool ( primary . is_partner or primary_partner_targets )
primary_restricted_labels = normalized_restricted_labels ( primary )
primary_is_doctor = bool ( primary . is_doctor )
primary_is_companion = bool ( primary . is_doctors_companion )
variant : str | None = None
if primary_is_doctor or primary_is_companion :
variant = " doctor_companion "
elif primary_is_partner :
variant = " partner "
options : list [ dict [ str , Any ] ] = [ ]
if variant is None :
return [ ] , None
for record in catalog . entries :
if record . display_name . casefold ( ) == primary_name :
continue
pairing_mode : str | None = None
role_label : str | None = None
restriction_label : str | None = None
record_name_cf = record . display_name . casefold ( )
is_direct_pair = bool ( primary_partner_targets and record_name_cf in primary_partner_targets )
if variant == " doctor_companion " :
if is_direct_pair :
pairing_mode = " partner_with "
role_label = " Partner With "
elif primary_is_doctor and record . is_doctors_companion :
pairing_mode = " doctor_companion "
role_label = " Doctor ' s Companion "
elif primary_is_companion and record . is_doctor :
pairing_mode = " doctor_companion "
role_label = " Doctor "
else :
if not record . is_partner or record . is_background :
continue
if primary_partner_targets :
if not is_direct_pair :
continue
pairing_mode = " partner_with "
role_label = " Partner With "
elif primary_restricted_labels :
restriction = shared_restricted_partner_label ( primary , record )
if not restriction :
continue
pairing_mode = " partner_restricted "
restriction_label = restriction
else :
if record . partner_with :
continue
if not getattr ( record , " has_plain_partner " , False ) :
continue
if record . is_doctors_companion :
continue
pairing_mode = " partner "
if not pairing_mode :
continue
options . append (
{
" name " : record . display_name ,
" color_code " : _color_code ( record . color_identity ) ,
" color_label " : _format_color_label ( record . color_identity ) ,
" partner_with " : list ( record . partner_with or ( ) ) ,
" pairing_mode " : pairing_mode ,
" role_label " : role_label ,
" restriction_label " : restriction_label ,
" mode_label " : _partner_mode_label ( pairing_mode ) ,
" image_url " : _scryfall_image_url ( record . display_name ) ,
" scryfall_url " : _scryfall_page_url ( record . display_name ) ,
}
)
options . sort ( key = lambda item : item [ " name " ] . casefold ( ) )
return options , variant
def _build_background_options ( ) - > list [ dict [ str , Any ] ] :
if not ENABLE_PARTNER_MECHANICS :
return [ ]
options : list [ dict [ str , Any ] ] = [ ]
try :
catalog = load_background_cards ( )
except FileNotFoundError as exc :
LOGGER . warning ( " background_cards_missing fallback_to_commander_catalog " , extra = { " error " : str ( exc ) } )
catalog = None
except Exception as exc : # pragma: no cover - unexpected loader failure
LOGGER . warning ( " background_cards_failed fallback_to_commander_catalog " , exc_info = exc )
catalog = None
if catalog and getattr ( catalog , " entries " , None ) :
seen : set [ str ] = set ( )
for card in catalog . entries :
name_key = card . display_name . casefold ( )
if name_key in seen :
continue
seen . add ( name_key )
options . append (
{
" name " : card . display_name ,
" color_code " : _color_code ( card . color_identity ) ,
" color_label " : _format_color_label ( card . color_identity ) ,
" image_url " : _scryfall_image_url ( card . display_name ) ,
" scryfall_url " : _scryfall_page_url ( card . display_name ) ,
" role_label " : " Background " ,
}
)
if options :
options . sort ( key = lambda item : item [ " name " ] . casefold ( ) )
return options
fallback_options = _background_options_from_commander_catalog ( )
if fallback_options :
return fallback_options
return options
def _background_options_from_commander_catalog ( ) - > list [ dict [ str , Any ] ] :
try :
catalog = load_commander_catalog ( )
except Exception as exc : # pragma: no cover - catalog load issues handled elsewhere
LOGGER . warning ( " commander_catalog_background_fallback_failed " , exc_info = exc )
return [ ]
seen : set [ str ] = set ( )
options : list [ dict [ str , Any ] ] = [ ]
for record in getattr ( catalog , " entries " , ( ) ) : # type: ignore[attr-defined]
if not getattr ( record , " is_background " , False ) :
continue
name = getattr ( record , " display_name " , None )
if not name :
continue
key = str ( name ) . casefold ( )
if key in seen :
continue
seen . add ( key )
color_identity = getattr ( record , " color_identity " , tuple ( ) )
options . append (
{
" name " : name ,
" color_code " : _color_code ( color_identity ) ,
" color_label " : _format_color_label ( color_identity ) ,
" image_url " : _scryfall_image_url ( name ) ,
" scryfall_url " : _scryfall_page_url ( name ) ,
" role_label " : " Background " ,
}
)
options . sort ( key = lambda item : item [ " name " ] . casefold ( ) )
return options
def _partner_ui_context (
commander_name : str ,
* ,
partner_enabled : bool ,
secondary_selection : str | None ,
background_selection : str | None ,
combined_preview : dict [ str , Any ] | None ,
warnings : Iterable [ str ] | None ,
partner_error : str | None ,
auto_note : str | None ,
auto_assigned : bool | None = None ,
auto_prefill_allowed : bool = True ,
) - > dict [ str , Any ] :
record = find_commander_record ( commander_name )
partner_options , partner_variant = _build_partner_options ( record )
supports_backgrounds = bool ( record . supports_backgrounds ) if record else False
background_options = _build_background_options ( ) if supports_backgrounds else [ ]
selected_secondary = ( secondary_selection or " " ) . strip ( )
selected_background = ( background_selection or " " ) . strip ( )
warnings_list = list ( warnings or [ ] )
preview_payload : dict [ str , Any ] | None = combined_preview if isinstance ( combined_preview , dict ) else None
preview_error : str | None = None
auto_prefill_applied = False
auto_default_name : str | None = None
auto_note_value = auto_note
if (
ENABLE_PARTNER_MECHANICS
and partner_variant == " partner "
and record
and record . partner_with
and not selected_secondary
and not selected_background
and auto_prefill_allowed
) :
target_names = [ name . strip ( ) for name in record . partner_with if str ( name ) . strip ( ) ]
for target in target_names :
for option in partner_options :
if option [ " name " ] . casefold ( ) == target . casefold ( ) :
selected_secondary = option [ " name " ]
auto_default_name = option [ " name " ]
auto_prefill_applied = True
if not auto_note_value :
auto_note_value = f " Automatically paired with { option [ ' name ' ] } (Partner With). "
break
if auto_prefill_applied :
break
partner_active = bool ( ( selected_secondary or selected_background ) and ENABLE_PARTNER_MECHANICS )
partner_capable = bool ( ENABLE_PARTNER_MECHANICS and ( partner_options or background_options ) )
placeholder = " Select a partner "
select_label = " Partner commander "
role_hint : str | None = None
if partner_variant == " doctor_companion " and record :
has_partner_with_option = any ( option . get ( " pairing_mode " ) == " partner_with " for option in partner_options )
if record . is_doctor :
if has_partner_with_option :
placeholder = " Select a companion or Partner With match "
select_label = " Companion or Partner "
role_hint = " Choose a Doctor ' s Companion or Partner With match for this Doctor. "
else :
placeholder = " Select a companion "
select_label = " Companion "
role_hint = " Choose a Doctor ' s Companion to pair with this Doctor. "
elif record . is_doctors_companion :
if has_partner_with_option :
placeholder = " Select a Doctor or Partner With match "
select_label = " Doctor or Partner "
role_hint = " Choose a Doctor or Partner With pairing for this companion. "
else :
placeholder = " Select a Doctor "
select_label = " Doctor partner "
role_hint = " Choose a Doctor to accompany this companion. "
suggestions_enabled = bool ( ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS )
suggestions_visible : list [ dict [ str , Any ] ] = [ ]
suggestions_hidden : list [ dict [ str , Any ] ] = [ ]
suggestions_total = 0
suggestions_metadata : dict [ str , Any ] = { }
suggestions_error : str | None = None
suggestions_loaded = False
if suggestions_enabled and record :
try :
suggestion_result = get_partner_suggestions ( record . display_name )
except Exception as exc : # pragma: no cover - defensive logging
LOGGER . warning ( " partner suggestions failed " , exc_info = exc )
suggestion_result = None
if suggestion_result is None :
suggestions_error = " Partner suggestions dataset is unavailable. "
else :
suggestions_loaded = True
partner_names = [ opt . get ( " name " ) for opt in ( partner_options or [ ] ) if opt . get ( " name " ) ]
background_names = [ opt . get ( " name " ) for opt in ( background_options or [ ] ) if opt . get ( " name " ) ]
try :
visible , hidden = suggestion_result . flatten ( partner_names , background_names , visible_limit = 3 )
except Exception as exc : # pragma: no cover - defensive
LOGGER . warning ( " partner suggestions flatten failed " , exc_info = exc )
visible = [ ]
hidden = [ ]
suggestions_visible = visible
suggestions_hidden = hidden
suggestions_total = suggestion_result . total
if isinstance ( suggestion_result . metadata , dict ) :
suggestions_metadata = dict ( suggestion_result . metadata )
context = {
" partner_feature_available " : ENABLE_PARTNER_MECHANICS ,
" partner_capable " : partner_capable ,
" partner_enabled " : partner_active ,
" selected_secondary_commander " : selected_secondary ,
" selected_background " : selected_background if supports_backgrounds else " " ,
" partner_options " : partner_options if partner_options else [ ] ,
" background_options " : background_options if background_options else [ ] ,
" primary_partner_with " : list ( record . partner_with ) if record else [ ] ,
" primary_supports_backgrounds " : supports_backgrounds ,
" primary_is_partner " : bool ( record . is_partner ) if record else False ,
" primary_commander_display " : record . display_name if record else commander_name ,
" partner_preview " : preview_payload ,
" partner_warnings " : warnings_list ,
" partner_error " : partner_error ,
" partner_auto_note " : auto_note_value ,
" partner_auto_assigned " : bool ( auto_prefill_applied or auto_assigned ) ,
" partner_auto_default " : auto_default_name ,
" partner_select_variant " : partner_variant ,
" partner_select_label " : select_label ,
" partner_select_placeholder " : placeholder ,
" partner_role_hint " : role_hint ,
" partner_suggestions_enabled " : suggestions_enabled ,
" partner_suggestions " : suggestions_visible ,
" partner_suggestions_hidden " : suggestions_hidden ,
" partner_suggestions_total " : suggestions_total ,
" partner_suggestions_metadata " : suggestions_metadata ,
" partner_suggestions_loaded " : suggestions_loaded ,
" partner_suggestions_error " : suggestions_error ,
" partner_suggestions_available " : bool ( suggestions_visible or suggestions_hidden ) ,
" partner_suggestions_has_hidden " : bool ( suggestions_hidden ) ,
" partner_suggestions_endpoint " : " /api/partner/suggestions " ,
}
context [ " has_partner_options " ] = bool ( partner_options )
context [ " has_background_options " ] = bool ( background_options )
context [ " partner_hidden_value " ] = " 1 " if partner_capable else " 0 "
context [ " partner_auto_opt_out " ] = not bool ( auto_prefill_allowed )
context [ " partner_prefill_available " ] = bool ( partner_variant == " partner " and partner_options )
if preview_payload is None and ENABLE_PARTNER_MECHANICS and ( selected_secondary or selected_background ) :
try :
builder = DeckBuilder ( output_func = lambda * _ : None , input_func = lambda * _ : " " , headless = True )
combined_obj = apply_partner_inputs (
builder ,
primary_name = commander_name ,
secondary_name = selected_secondary or None ,
background_name = selected_background or None ,
feature_enabled = True ,
)
except CommanderPartnerError as exc :
preview_error = str ( exc ) or " Invalid partner selection. "
except Exception as exc :
preview_error = f " Partner preview failed: { exc } "
else :
if combined_obj is not None :
preview_payload = _combined_to_payload ( combined_obj )
if combined_obj . warnings :
for warn in combined_obj . warnings :
if warn not in warnings_list :
warnings_list . append ( warn )
if preview_payload :
context [ " partner_preview " ] = preview_payload
preview_tags = preview_payload . get ( " theme_tags " )
if preview_tags :
context [ " partner_theme_tags " ] = list ( preview_tags )
if preview_error and not partner_error :
context [ " partner_error " ] = preview_error
partner_error = preview_error
context [ " partner_warnings " ] = warnings_list
return context
def _resolve_partner_selection (
commander_name : str ,
* ,
feature_enabled : bool ,
partner_enabled : bool ,
secondary_candidate : str | None ,
background_candidate : str | None ,
auto_opt_out : bool = False ,
selection_source : str | None = None ,
) - > tuple [
str | None ,
dict [ str , Any ] | None ,
list [ str ] ,
str | None ,
str | None ,
str | None ,
str | None ,
bool ,
] :
if not ( feature_enabled and ENABLE_PARTNER_MECHANICS ) :
return None , None , [ ] , None , None , None , None , False
secondary = ( secondary_candidate or " " ) . strip ( )
background = ( background_candidate or " " ) . strip ( )
auto_note : str | None = None
auto_assigned = False
selection_source_clean = ( selection_source or " " ) . strip ( ) . lower ( ) or None
record = find_commander_record ( commander_name )
partner_options , partner_variant = _build_partner_options ( record )
supports_backgrounds = bool ( record and record . supports_backgrounds )
background_options = _build_background_options ( ) if supports_backgrounds else [ ]
if not partner_enabled and not secondary and not background :
return None , None , [ ] , None , None , None , None , False
if not supports_backgrounds :
background = " "
if not partner_options :
secondary = " "
if secondary and background :
return " Provide either a secondary commander or a background, not both. " , None , [ ] , auto_note , secondary , background , None , False
option_lookup = { opt [ " name " ] . casefold ( ) : opt for opt in partner_options }
if secondary :
key = secondary . casefold ( )
if key not in option_lookup :
return " Selected partner is not valid for this commander. " , None , [ ] , auto_note , secondary , background or None , None , False
if background :
normalized_backgrounds = { opt [ " name " ] . casefold ( ) for opt in background_options }
if background . casefold ( ) not in normalized_backgrounds :
return " Selected background is not available. " , None , [ ] , auto_note , secondary or None , background , None , False
if not secondary and not background and not auto_opt_out and partner_variant == " partner " and record and record . partner_with :
target_names = [ name . strip ( ) for name in record . partner_with if str ( name ) . strip ( ) ]
for target in target_names :
opt = option_lookup . get ( target . casefold ( ) )
if opt :
secondary = opt [ " name " ]
auto_note = f " Automatically paired with { secondary } (Partner With). "
auto_assigned = True
break
if not secondary and not background :
return None , None , [ ] , auto_note , None , None , None , auto_assigned
builder = DeckBuilder ( output_func = lambda * _ : None , input_func = lambda * _ : " " , headless = True )
try :
combined = apply_partner_inputs (
builder ,
primary_name = commander_name ,
secondary_name = secondary or None ,
background_name = background or None ,
feature_enabled = True ,
selection_source = selection_source_clean ,
)
except CommanderPartnerError as exc :
message = str ( exc ) or " Invalid partner selection. "
return message , None , [ ] , auto_note , secondary or None , background or None , None , auto_assigned
except Exception as exc :
return f " Partner selection failed: { exc } " , None , [ ] , auto_note , secondary or None , background or None , None , auto_assigned
if combined is None :
return " Unable to resolve partner selection. " , None , [ ] , auto_note , secondary or None , background or None , None , auto_assigned
payload = _combined_to_payload ( combined )
warnings = payload . get ( " warnings " , [ ] ) or [ ]
mode = payload . get ( " partner_mode " )
if mode == " background " :
resolved_background = payload . get ( " secondary_name " )
return None , payload , warnings , auto_note , None , resolved_background , mode , auto_assigned
return None , payload , warnings , auto_note , payload . get ( " secondary_name " ) , None , mode , auto_assigned
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
router = APIRouter ( prefix = " /build " )
2025-10-07 15:56:57 -07:00
@router.post ( " /must-haves/toggle " , response_class = HTMLResponse )
async def toggle_must_haves (
request : Request ,
card_name : str = Form ( . . . ) ,
list_type : str = Form ( . . . ) ,
enabled : str = Form ( " 1 " ) ,
) :
if not ALLOW_MUST_HAVES :
return JSONResponse ( { " error " : " Must-have lists are disabled " } , status_code = 403 )
name = str ( card_name or " " ) . strip ( )
if not name :
return JSONResponse ( { " error " : " Card name is required " } , status_code = 400 )
list_key = str ( list_type or " " ) . strip ( ) . lower ( )
if list_key not in { " include " , " exclude " } :
return JSONResponse ( { " error " : " Unsupported toggle type " } , status_code = 400 )
enabled_flag = str ( enabled ) . strip ( ) . lower ( ) in { " 1 " , " true " , " yes " , " on " }
sid = request . cookies . get ( " sid " ) or request . headers . get ( " X-Session-ID " )
if not sid :
sid = new_sid ( )
sess = get_session ( sid )
includes = list ( sess . get ( " include_cards " ) or [ ] )
excludes = list ( sess . get ( " exclude_cards " ) or [ ] )
include_lookup = { str ( v ) . strip ( ) . lower ( ) : str ( v ) for v in includes if str ( v ) . strip ( ) }
exclude_lookup = { str ( v ) . strip ( ) . lower ( ) : str ( v ) for v in excludes if str ( v ) . strip ( ) }
key = name . lower ( )
display_name = include_lookup . get ( key ) or exclude_lookup . get ( key ) or name
changed = False
include_limit = 10
exclude_limit = 15
def _remove_casefold ( items : list [ str ] , item_key : str ) - > list [ str ] :
return [ c for c in items if str ( c ) . strip ( ) . lower ( ) != item_key ]
if list_key == " include " :
if enabled_flag :
if key not in include_lookup :
if len ( include_lookup ) > = include_limit :
return JSONResponse ( { " error " : f " Include limit reached ( { include_limit } ). " } , status_code = 400 )
includes . append ( name )
include_lookup [ key ] = name
changed = True
if key in exclude_lookup :
excludes = _remove_casefold ( excludes , key )
exclude_lookup . pop ( key , None )
changed = True
else :
if key in include_lookup :
includes = _remove_casefold ( includes , key )
include_lookup . pop ( key , None )
changed = True
else : # exclude
if enabled_flag :
if key not in exclude_lookup :
if len ( exclude_lookup ) > = exclude_limit :
return JSONResponse ( { " error " : f " Exclude limit reached ( { exclude_limit } ). " } , status_code = 400 )
excludes . append ( name )
exclude_lookup [ key ] = name
changed = True
if key in include_lookup :
includes = _remove_casefold ( includes , key )
include_lookup . pop ( key , None )
changed = True
else :
if key in exclude_lookup :
excludes = _remove_casefold ( excludes , key )
exclude_lookup . pop ( key , None )
changed = True
if changed :
sess [ " include_cards " ] = includes
sess [ " exclude_cards " ] = excludes
if " include_exclude_diagnostics " in sess :
try :
del sess [ " include_exclude_diagnostics " ]
except Exception :
pass
2025-10-08 11:38:30 -07:00
response = _render_include_exclude_summary ( request , sess , sid )
2025-10-07 15:56:57 -07:00
try :
log_include_exclude_toggle (
request ,
card_name = display_name ,
action = list_key ,
enabled = enabled_flag ,
include_count = len ( includes ) ,
exclude_count = len ( excludes ) ,
)
except Exception :
pass
trigger_payload = {
" card " : display_name ,
" list " : list_key ,
" enabled " : enabled_flag ,
" include_count " : len ( includes ) ,
" exclude_count " : len ( excludes ) ,
}
try :
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( response , { " must-haves:toggle " : trigger_payload } )
2025-10-07 15:56:57 -07:00
except Exception :
pass
return response
2025-09-02 11:39:14 -07:00
# Alternatives cache moved to services/alts_utils
2025-08-28 14:57:22 -07:00
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
2025-10-06 09:17:59 -07:00
@router.post ( " /partner/preview " , response_class = JSONResponse )
async def build_partner_preview (
request : Request ,
commander : str = Form ( . . . ) ,
partner_enabled : str | None = Form ( None ) ,
secondary_commander : str | None = Form ( None ) ,
background : str | None = Form ( None ) ,
partner_auto_opt_out : str | None = Form ( None ) ,
scope : str | None = Form ( None ) ,
selection_source : str | None = Form ( None ) ,
) - > JSONResponse :
partner_feature_enabled = ENABLE_PARTNER_MECHANICS
raw_partner_enabled = ( partner_enabled or " " ) . strip ( ) . lower ( )
partner_flag = partner_feature_enabled and raw_partner_enabled in { " 1 " , " true " , " on " , " yes " }
auto_opt_out_flag = ( partner_auto_opt_out or " " ) . strip ( ) . lower ( ) in { " 1 " , " true " , " on " , " yes " }
selection_source_value = ( selection_source or " " ) . strip ( ) . lower ( ) or None
try :
(
partner_error ,
combined_payload ,
partner_warnings ,
partner_auto_note ,
resolved_secondary ,
resolved_background ,
partner_mode ,
partner_auto_assigned_flag ,
) = _resolve_partner_selection (
commander ,
feature_enabled = partner_feature_enabled ,
partner_enabled = partner_flag ,
secondary_candidate = secondary_commander ,
background_candidate = background ,
auto_opt_out = auto_opt_out_flag ,
selection_source = selection_source_value ,
)
except Exception as exc : # pragma: no cover - defensive
return JSONResponse (
{
" ok " : False ,
" error " : f " Partner preview failed: { exc } " ,
" scope " : scope or " " ,
}
)
partner_ctx = _partner_ui_context (
commander ,
partner_enabled = partner_flag ,
secondary_selection = resolved_secondary or secondary_commander ,
background_selection = resolved_background or background ,
combined_preview = combined_payload ,
warnings = partner_warnings ,
partner_error = partner_error ,
auto_note = partner_auto_note ,
auto_assigned = partner_auto_assigned_flag ,
auto_prefill_allowed = not auto_opt_out_flag ,
)
preview_payload = partner_ctx . get ( " partner_preview " )
theme_tags = partner_ctx . get ( " partner_theme_tags " ) or [ ]
warnings_list = partner_ctx . get ( " partner_warnings " ) or partner_warnings or [ ]
response = {
" ok " : True ,
" scope " : scope or " " ,
" preview " : preview_payload ,
" theme_tags " : theme_tags ,
" warnings " : warnings_list ,
" auto_note " : partner_auto_note ,
" resolved_secondary " : resolved_secondary ,
" resolved_background " : resolved_background ,
" partner_mode " : partner_mode ,
" auto_assigned " : bool ( partner_auto_assigned_flag ) ,
}
if partner_error :
response [ " error " ] = partner_error
try :
log_partner_suggestion_selected (
request ,
commander = commander ,
scope = scope ,
partner_enabled = partner_flag ,
auto_opt_out = auto_opt_out_flag ,
auto_assigned = bool ( partner_auto_assigned_flag ) ,
selection_source = selection_source_value ,
secondary_candidate = secondary_commander ,
background_candidate = background ,
resolved_secondary = resolved_secondary ,
resolved_background = resolved_background ,
partner_mode = partner_mode ,
has_preview = bool ( preview_payload ) ,
warnings = warnings_list ,
error = response . get ( " error " ) ,
)
except Exception : # pragma: no cover - telemetry should not break responses
pass
return JSONResponse ( response )
2025-10-03 10:43:24 -07:00
def _custom_theme_context (
request : Request ,
sess : dict ,
* ,
message : str | None = None ,
level : str = " info " ,
) - > dict [ str , Any ] :
""" Assemble the Additional Themes section context for the modal. """
if not ENABLE_CUSTOM_THEMES :
return {
" request " : request ,
" theme_state " : None ,
" theme_message " : message ,
" theme_message_level " : level ,
" theme_limit " : USER_THEME_LIMIT ,
" enable_custom_themes " : False ,
}
theme_mgr . set_limit ( sess , USER_THEME_LIMIT )
state = theme_mgr . get_view_state ( sess , default_mode = DEFAULT_THEME_MATCH_MODE )
return {
" request " : request ,
" theme_state " : state ,
" theme_message " : message ,
" theme_message_level " : level ,
" theme_limit " : USER_THEME_LIMIT ,
" enable_custom_themes " : ENABLE_CUSTOM_THEMES ,
}
_INVALID_THEME_MESSAGE = (
" Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores. "
)
2025-08-29 09:19:03 -07:00
def _rebuild_ctx_with_multicopy ( sess : dict ) - > None :
""" Rebuild the staged context so Multi-Copy runs first, avoiding overfill.
This ensures the added cards are accounted for before lands and later phases ,
which keeps totals near targets and shows the multi - copy additions ahead of basics .
"""
try :
if not sess or not sess . get ( " commander " ) :
return
# Build fresh ctx with the same options, threading multi_copy explicitly
opts = orch . bracket_options ( )
default_bracket = ( opts [ 0 ] [ " level " ] if opts else 1 )
bracket_val = sess . get ( " bracket " )
try :
2025-09-02 11:39:14 -07:00
safe_bracket = int ( bracket_val ) if bracket_val is not None else default_bracket
2025-08-29 09:19:03 -07:00
except Exception :
safe_bracket = int ( default_bracket )
ideals_val = sess . get ( " ideals " ) or orch . ideal_defaults ( )
use_owned = bool ( sess . get ( " use_owned_only " ) )
prefer = bool ( sess . get ( " prefer_owned " ) )
2025-09-02 11:39:14 -07:00
owned_names = owned_names_helper ( ) if ( use_owned or prefer ) else None
2025-08-29 09:19:03 -07:00
locks = list ( sess . get ( " locks " , [ ] ) )
sess [ " build_ctx " ] = orch . start_build_ctx (
commander = sess . get ( " commander " ) ,
tags = sess . get ( " tags " , [ ] ) ,
bracket = safe_bracket ,
ideals = ideals_val ,
tag_mode = sess . get ( " tag_mode " , " AND " ) ,
use_owned_only = use_owned ,
prefer_owned = prefer ,
owned_names = owned_names ,
locks = locks ,
custom_export_base = sess . get ( " custom_export_base " ) ,
multi_copy = sess . get ( " multi_copy " ) ,
2025-09-01 16:55:24 -07:00
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 " ) ) ,
2025-10-02 15:31:05 -07:00
swap_mdfc_basics = bool ( sess . get ( " swap_mdfc_basics " ) ) ,
2025-08-29 09:19:03 -07:00
)
except Exception :
# If rebuild fails (e.g., commander not found in test), fall back to injecting
# a minimal Multi-Copy stage on the existing builder so the UI can render additions.
try :
ctx = sess . get ( " build_ctx " )
if not isinstance ( ctx , dict ) :
return
b = ctx . get ( " builder " )
if b is None :
return
# Thread selection onto the builder; runner will be resilient without full DFs
try :
setattr ( b , " _web_multi_copy " , sess . get ( " multi_copy " ) or None )
except Exception :
pass
# Ensure minimal structures exist
try :
if not isinstance ( getattr ( b , " card_library " , None ) , dict ) :
b . card_library = { }
except Exception :
pass
try :
if not isinstance ( getattr ( b , " ideal_counts " , None ) , dict ) :
b . ideal_counts = { }
except Exception :
pass
# Inject a single Multi-Copy stage
ctx [ " stages " ] = [ { " key " : " multi_copy " , " label " : " Multi-Copy Package " , " runner_name " : " __add_multi_copy__ " } ]
ctx [ " idx " ] = 0
ctx [ " last_visible_idx " ] = 0
except Exception :
# Leave existing context untouched on unexpected failure
pass
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
@router.get ( " / " , response_class = HTMLResponse )
async def build_index ( request : Request ) - > HTMLResponse :
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-09-30 15:49:08 -07:00
# Seed commander from query string when arriving from commander browser
q_commander = None
try :
q_commander = request . query_params . get ( " commander " )
if q_commander :
# Persist a human-friendly commander name into session for the wizard
sess [ " commander " ] = str ( q_commander )
except Exception :
pass
return_url = None
try :
raw_return = request . query_params . get ( " return " )
if raw_return :
parsed = urlparse ( raw_return )
if not parsed . scheme and not parsed . netloc and parsed . path :
safe_path = parsed . path if parsed . path . startswith ( " / " ) else f " / { parsed . path } "
safe_return = safe_path
if parsed . query :
safe_return + = f " ? { parsed . query } "
if parsed . fragment :
safe_return + = f " # { parsed . fragment } "
return_url = safe_return
except Exception :
return_url = None
if q_commander :
try :
log_commander_create_deck (
request ,
commander = str ( q_commander ) ,
return_url = return_url ,
)
except Exception :
pass
2025-08-26 20:00:07 -07:00
# Determine last step (fallback heuristics if not set)
last_step = sess . get ( " last_step " )
if not last_step :
if sess . get ( " build_ctx " ) :
last_step = 5
elif sess . get ( " ideals " ) :
last_step = 4
elif sess . get ( " bracket " ) :
last_step = 3
elif sess . get ( " commander " ) :
last_step = 2
else :
last_step = 1
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
resp = templates . TemplateResponse (
2025-09-12 10:50:57 -07:00
request ,
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
" build/index.html " ,
2025-08-26 20:00:07 -07:00
{
" sid " : sid ,
" commander " : sess . get ( " commander " ) ,
" tags " : sess . get ( " tags " , [ ] ) ,
2025-08-28 14:57:22 -07:00
" name " : sess . get ( " custom_export_base " ) ,
2025-08-26 20:00:07 -07:00
" last_step " : last_step ,
2025-09-30 15:49:08 -07:00
" return_url " : return_url ,
2025-08-26 20:00:07 -07:00
} ,
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
)
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
2025-09-03 18:00:06 -07:00
# Support /build without trailing slash
@router.get ( " " , response_class = HTMLResponse )
async def build_index_alias ( request : Request ) - > HTMLResponse :
return await build_index ( request )
2025-08-29 09:19:03 -07:00
@router.get ( " /multicopy/check " , response_class = HTMLResponse )
async def multicopy_check ( request : Request ) - > HTMLResponse :
""" If current commander/tags suggest a multi-copy archetype, render a choose-one modal.
Returns empty content when not applicable to avoid flashing a modal unnecessarily .
"""
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
commander = str ( sess . get ( " commander " ) or " " ) . strip ( )
tags = list ( sess . get ( " tags " ) or [ ] )
if not commander :
return HTMLResponse ( " " )
# Avoid re-prompting repeatedly for the same selection context
key = commander + " || " + " , " . join ( sorted ( [ str ( t ) . strip ( ) . lower ( ) for t in tags if str ( t ) . strip ( ) ] ) )
seen = set ( sess . get ( " mc_seen_keys " , [ ] ) or [ ] )
if key in seen :
return HTMLResponse ( " " )
# Build a light DeckBuilder seeded with commander + tags (no heavy data load required)
try :
tmp = DeckBuilder ( output_func = lambda * _ : None , input_func = lambda * _ : " " , headless = True )
df = tmp . load_commander_data ( )
row = df [ df [ " name " ] . astype ( str ) == commander ]
if row . empty :
return HTMLResponse ( " " )
tmp . _apply_commander_selection ( row . iloc [ 0 ] )
tmp . selected_tags = list ( tags or [ ] )
try :
tmp . primary_tag = tmp . selected_tags [ 0 ] if len ( tmp . selected_tags ) > 0 else None
tmp . secondary_tag = tmp . selected_tags [ 1 ] if len ( tmp . selected_tags ) > 1 else None
tmp . tertiary_tag = tmp . selected_tags [ 2 ] if len ( tmp . selected_tags ) > 2 else None
except Exception :
pass
# Establish color identity from the selected commander
try :
tmp . determine_color_identity ( )
except Exception :
pass
# Detect viable archetypes
results = bu . detect_viable_multi_copy_archetypes ( tmp ) or [ ]
if not results :
# Remember this key to avoid re-checking until tags/commander change
try :
seen . add ( key )
sess [ " mc_seen_keys " ] = list ( seen )
except Exception :
pass
return HTMLResponse ( " " )
# Render modal template with top N (cap small for UX)
items = results [ : 5 ]
ctx = {
" request " : request ,
" items " : items ,
" commander " : commander ,
" tags " : tags ,
}
return templates . TemplateResponse ( " build/_multi_copy_modal.html " , ctx )
except Exception :
return HTMLResponse ( " " )
@router.post ( " /multicopy/save " , response_class = HTMLResponse )
async def multicopy_save (
request : Request ,
choice_id : str = Form ( None ) ,
count : int = Form ( None ) ,
thrumming : str | None = Form ( None ) ,
skip : str | None = Form ( None ) ,
) - > HTMLResponse :
""" Persist user selection (or skip) for multi-copy archetype in session and close modal.
Returns a tiny confirmation chip via OOB swap ( optional ) and removes the modal .
"""
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
commander = str ( sess . get ( " commander " ) or " " ) . strip ( )
tags = list ( sess . get ( " tags " ) or [ ] )
key = commander + " || " + " , " . join ( sorted ( [ str ( t ) . strip ( ) . lower ( ) for t in tags if str ( t ) . strip ( ) ] ) )
# Update seen set to avoid re-prompt next load
seen = set ( sess . get ( " mc_seen_keys " , [ ] ) or [ ] )
seen . add ( key )
sess [ " mc_seen_keys " ] = list ( seen )
# Handle skip explicitly
if skip and str ( skip ) . strip ( ) in ( " 1 " , " true " , " on " , " yes " ) :
# Clear any prior choice for this run
try :
if sess . get ( " multi_copy " ) :
del sess [ " multi_copy " ]
if sess . get ( " mc_applied_key " ) :
del sess [ " mc_applied_key " ]
except Exception :
pass
# Return nothing (modal will be removed client-side)
# Also emit an OOB chip indicating skip
chip = (
' <div id= " last-action " hx-swap-oob= " true " > '
' <span class= " chip " title= " Click to dismiss " >Dismissed multi-copy suggestions</span> '
' </div> '
)
return HTMLResponse ( chip )
# Persist selection when provided
payload = None
try :
meta = bc . MULTI_COPY_ARCHETYPES . get ( str ( choice_id ) , { } )
name = meta . get ( " name " ) or str ( choice_id )
printed_cap = meta . get ( " printed_cap " )
# Coerce count with bounds: default -> rec_window[0], cap by printed_cap when present
if count is None :
count = int ( meta . get ( " default_count " , 25 ) )
try :
count = int ( count )
except Exception :
count = int ( meta . get ( " default_count " , 25 ) )
if isinstance ( printed_cap , int ) and printed_cap > 0 :
count = max ( 1 , min ( printed_cap , count ) )
payload = {
" id " : str ( choice_id ) ,
" name " : name ,
" count " : int ( count ) ,
" thrumming " : True if ( thrumming and str ( thrumming ) . strip ( ) in ( " 1 " , " true " , " on " , " yes " ) ) else False ,
}
sess [ " multi_copy " ] = payload
# Mark as not yet applied so the next build start/continue can account for it once
try :
if sess . get ( " mc_applied_key " ) :
del sess [ " mc_applied_key " ]
except Exception :
pass
# If there's an active build context, rebuild it so Multi-Copy runs first
if sess . get ( " build_ctx " ) :
_rebuild_ctx_with_multicopy ( sess )
except Exception :
payload = None
# Return OOB chip summarizing the selection
if payload :
chip = (
' <div id= " last-action " hx-swap-oob= " true " > '
f ' <span class= " chip " title= " Click to dismiss " >Selected multi-copy: '
f " <strong> { _esc ( payload . get ( ' name ' , ' ' ) ) } </strong> x { int ( payload . get ( ' count ' , 0 ) ) } "
f " { ' + Thrumming Stone ' if payload . get ( ' thrumming ' ) else ' ' } </span> "
' </div> '
)
else :
chip = (
' <div id= " last-action " hx-swap-oob= " true " > '
' <span class= " chip " title= " Click to dismiss " >Saved</span> '
' </div> '
)
return HTMLResponse ( chip )
2025-09-01 16:55:24 -07:00
2025-08-28 14:57:22 -07:00
# Unified "New Deck" modal (steps 1– 3 condensed)
@router.get ( " /new " , response_class = HTMLResponse )
async def build_new_modal ( request : Request ) - > HTMLResponse :
""" Return the New Deck modal content (for an overlay). """
sid = request . cookies . get ( " sid " ) or new_sid ( )
2025-10-02 15:31:05 -07:00
sess = get_session ( sid )
2025-10-14 16:09:58 -07:00
# Clear build context to allow skip controls to work
# (Otherwise toggle endpoint thinks build is in progress)
if " build_ctx " in sess :
try :
del sess [ " build_ctx " ]
except Exception :
pass
# M2: Clear all skip preferences for true "New Deck"
skip_keys = [
" skip_lands " , " skip_to_misc " , " skip_basics " , " skip_staples " ,
" skip_kindred " , " skip_fetches " , " skip_duals " , " skip_triomes " ,
" skip_all_creatures " ,
" skip_creature_primary " , " skip_creature_secondary " , " skip_creature_fill " ,
" skip_all_spells " ,
" skip_ramp " , " skip_removal " , " skip_wipes " , " skip_card_advantage " ,
" skip_protection " , " skip_spell_fill " ,
" skip_post_adjust "
]
for key in skip_keys :
sess . pop ( key , None )
2025-10-28 08:21:52 -07:00
# M2: Clear commander and form selections for fresh start
commander_keys = [
" commander " , " partner " , " background " , " commander_mode " ,
" themes " , " bracket "
]
for key in commander_keys :
sess . pop ( key , None )
2025-10-03 10:43:24 -07:00
theme_context = _custom_theme_context ( request , sess )
2025-08-28 14:57:22 -07:00
ctx = {
" request " : request ,
" brackets " : orch . bracket_options ( ) ,
" labels " : orch . ideal_labels ( ) ,
" defaults " : orch . ideal_defaults ( ) ,
2025-09-09 09:36:17 -07:00
" allow_must_haves " : ALLOW_MUST_HAVES , # Add feature flag
2025-10-07 15:56:57 -07:00
" show_must_have_buttons " : SHOW_MUST_HAVE_BUTTONS ,
2025-10-03 10:43:24 -07:00
" enable_custom_themes " : ENABLE_CUSTOM_THEMES ,
2025-10-20 18:29:53 -07:00
" enable_batch_build " : ENABLE_BATCH_BUILD ,
2025-10-14 16:45:49 -07:00
" ideals_ui_mode " : WEB_IDEALS_UI , # 'input' or 'slider'
2025-10-02 15:31:05 -07:00
" form " : {
" prefer_combos " : bool ( sess . get ( " prefer_combos " ) ) ,
" combo_count " : sess . get ( " combo_target_count " ) ,
" combo_balance " : sess . get ( " combo_balance " ) ,
" enable_multicopy " : bool ( sess . get ( " multi_copy " ) ) ,
" use_owned_only " : bool ( sess . get ( " use_owned_only " ) ) ,
" prefer_owned " : bool ( sess . get ( " prefer_owned " ) ) ,
" swap_mdfc_basics " : bool ( sess . get ( " swap_mdfc_basics " ) ) ,
2025-10-14 16:45:49 -07:00
# Add ideal values from session (will be None on first load, triggering defaults)
" ramp " : sess . get ( " ideals " , { } ) . get ( " ramp " ) ,
" lands " : sess . get ( " ideals " , { } ) . get ( " lands " ) ,
" basic_lands " : sess . get ( " ideals " , { } ) . get ( " basic_lands " ) ,
" creatures " : sess . get ( " ideals " , { } ) . get ( " creatures " ) ,
" removal " : sess . get ( " ideals " , { } ) . get ( " removal " ) ,
" wipes " : sess . get ( " ideals " , { } ) . get ( " wipes " ) ,
" card_advantage " : sess . get ( " ideals " , { } ) . get ( " card_advantage " ) ,
" protection " : sess . get ( " ideals " , { } ) . get ( " protection " ) ,
2025-10-02 15:31:05 -07:00
} ,
2025-10-06 09:17:59 -07:00
" tag_slot_html " : None ,
2025-08-28 14:57:22 -07:00
}
2025-10-03 10:43:24 -07:00
for key , value in theme_context . items ( ) :
if key == " request " :
continue
ctx [ key ] = value
2025-08-28 14:57:22 -07:00
resp = templates . TemplateResponse ( " build/_new_deck_modal.html " , ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
@router.get ( " /new/candidates " , response_class = HTMLResponse )
async def build_new_candidates ( request : Request , commander : str = Query ( " " ) ) - > HTMLResponse :
""" Return a small list of commander candidates for the modal live search. """
q = ( commander or " " ) . strip ( )
items = orch . commander_candidates ( q , limit = 8 ) if q else [ ]
2025-10-02 15:31:05 -07:00
candidates : list [ dict [ str , Any ] ] = [ ]
for name , score , colors in items :
detail = lookup_commander_detail ( name )
preferred = name
warning = None
if detail :
eligible_raw = detail . get ( " eligible_faces " )
eligible = [ str ( face ) . strip ( ) for face in eligible_raw or [ ] if str ( face ) . strip ( ) ] if isinstance ( eligible_raw , list ) else [ ]
norm_name = str ( name ) . strip ( ) . casefold ( )
eligible_norms = [ face . casefold ( ) for face in eligible ]
if eligible and norm_name not in eligible_norms :
preferred = eligible [ 0 ]
primary = str ( detail . get ( " primary_face " ) or detail . get ( " name " ) or name ) . strip ( )
if len ( eligible ) == 1 :
warning = (
f " Use the back face ' { preferred } ' when building. Front face ' { primary } ' can ' t lead a deck. "
)
else :
faces = " , " . join ( f " ' { face } ' " for face in eligible )
warning = (
f " This commander only works from specific faces: { faces } . "
)
candidates . append (
{
" display " : name ,
" value " : preferred ,
" score " : score ,
" colors " : colors ,
" warning " : warning ,
}
)
ctx = { " request " : request , " query " : q , " candidates " : candidates }
2025-08-28 14:57:22 -07:00
return templates . TemplateResponse ( " build/_new_deck_candidates.html " , ctx )
@router.get ( " /new/inspect " , response_class = HTMLResponse )
async def build_new_inspect ( request : Request , name : str = Query ( . . . ) ) - > HTMLResponse :
""" When a candidate is chosen in the modal, show the commander preview and tag chips (OOB updates). """
info = orch . commander_select ( name )
if not info . get ( " ok " ) :
return HTMLResponse ( f ' <div class= " muted " >Commander not found: { name } </div> ' )
tags = orch . tags_for_commander ( info [ " name " ] ) or [ ]
recommended = orch . recommended_tags_for_commander ( info [ " name " ] ) if tags else [ ]
recommended_reasons = orch . recommended_tag_reasons_for_commander ( info [ " name " ] ) if tags else { }
2025-10-02 15:31:05 -07:00
exclusion_detail = lookup_commander_detail ( info [ " name " ] )
2025-08-28 14:57:22 -07:00
# Render tags slot content and OOB commander preview simultaneously
2025-09-03 18:00:06 -07:00
# Game Changer flag for this commander (affects bracket UI in modal via tags partial consumer)
is_gc = False
try :
is_gc = bool ( info [ " name " ] in getattr ( bc , ' GAME_CHANGERS ' , [ ] ) )
except Exception :
is_gc = False
2025-08-28 14:57:22 -07:00
ctx = {
" request " : request ,
2025-10-02 15:31:05 -07:00
" commander " : { " name " : info [ " name " ] , " exclusion " : exclusion_detail } ,
2025-08-28 14:57:22 -07:00
" tags " : tags ,
" recommended " : recommended ,
" recommended_reasons " : recommended_reasons ,
2025-09-03 18:00:06 -07:00
" gc_commander " : is_gc ,
" brackets " : orch . bracket_options ( ) ,
2025-08-28 14:57:22 -07:00
}
2025-10-06 09:17:59 -07:00
ctx . update (
_partner_ui_context (
info [ " name " ] ,
partner_enabled = False ,
secondary_selection = None ,
background_selection = None ,
combined_preview = None ,
warnings = None ,
partner_error = None ,
auto_note = None ,
)
)
partner_tags = ctx . get ( " partner_theme_tags " ) or [ ]
if partner_tags :
merged_tags : list [ str ] = [ ]
seen : set [ str ] = set ( )
for source in ( partner_tags , tags ) :
for tag in source :
token = str ( tag ) . strip ( )
if not token :
continue
key = token . casefold ( )
if key in seen :
continue
seen . add ( key )
merged_tags . append ( token )
ctx [ " tags " ] = merged_tags
2025-10-28 08:21:52 -07:00
# Deduplicate recommended: remove any that are already in partner_tags
partner_tags_lower = { str ( tag ) . strip ( ) . casefold ( ) for tag in partner_tags }
2025-10-06 09:17:59 -07:00
existing_recommended = ctx . get ( " recommended " ) or [ ]
2025-10-28 08:21:52 -07:00
deduplicated_recommended = [
tag for tag in existing_recommended
if str ( tag ) . strip ( ) . casefold ( ) not in partner_tags_lower
]
ctx [ " recommended " ] = deduplicated_recommended
2025-10-06 09:17:59 -07:00
reason_map = dict ( ctx . get ( " recommended_reasons " ) or { } )
for tag in partner_tags :
if tag not in reason_map :
reason_map [ tag ] = " Synergizes with partner pairing "
ctx [ " recommended_reasons " ] = reason_map
2025-08-28 14:57:22 -07:00
return templates . TemplateResponse ( " build/_new_deck_tags.html " , ctx )
2025-09-02 16:03:12 -07:00
@router.get ( " /new/multicopy " , response_class = HTMLResponse )
async def build_new_multicopy (
request : Request ,
commander : str = Query ( " " ) ,
primary_tag : str | None = Query ( None ) ,
secondary_tag : str | None = Query ( None ) ,
tertiary_tag : str | None = Query ( None ) ,
tag_mode : str | None = Query ( " AND " ) ,
) - > HTMLResponse :
""" Return multi-copy suggestions for the New Deck modal based on commander + selected tags.
This does not mutate the session ; it simply renders a form snippet that posts with the main modal .
"""
name = ( commander or " " ) . strip ( )
if not name :
return HTMLResponse ( " " )
try :
tmp = DeckBuilder ( output_func = lambda * _ : None , input_func = lambda * _ : " " , headless = True )
df = tmp . load_commander_data ( )
row = df [ df [ " name " ] . astype ( str ) == name ]
if row . empty :
return HTMLResponse ( " " )
tmp . _apply_commander_selection ( row . iloc [ 0 ] )
tags = [ t for t in [ primary_tag , secondary_tag , tertiary_tag ] if t ]
tmp . selected_tags = list ( tags or [ ] )
try :
tmp . primary_tag = tmp . selected_tags [ 0 ] if len ( tmp . selected_tags ) > 0 else None
tmp . secondary_tag = tmp . selected_tags [ 1 ] if len ( tmp . selected_tags ) > 1 else None
tmp . tertiary_tag = tmp . selected_tags [ 2 ] if len ( tmp . selected_tags ) > 2 else None
except Exception :
pass
try :
tmp . determine_color_identity ( )
except Exception :
pass
results = bu . detect_viable_multi_copy_archetypes ( tmp ) or [ ]
# For the New Deck modal, only show suggestions where the matched tags intersect
# the explicitly selected tags (ignore commander-default themes).
sel_tags = { str ( t ) . strip ( ) . lower ( ) for t in ( tags or [ ] ) if str ( t ) . strip ( ) }
def _matched_reason_tags ( item : dict ) - > set [ str ] :
out = set ( )
try :
for r in item . get ( ' reasons ' , [ ] ) or [ ] :
if not isinstance ( r , str ) :
continue
rl = r . strip ( ) . lower ( )
if rl . startswith ( ' tags: ' ) :
body = rl . split ( ' tags: ' , 1 ) [ 1 ] . strip ( )
parts = [ p . strip ( ) for p in body . split ( ' , ' ) if p . strip ( ) ]
out . update ( parts )
except Exception :
return set ( )
return out
if sel_tags :
results = [ it for it in results if ( _matched_reason_tags ( it ) & sel_tags ) ]
else :
# If no selected tags, do not show any multi-copy suggestions in the modal
results = [ ]
if not results :
return HTMLResponse ( " " )
items = results [ : 5 ]
ctx = { " request " : request , " items " : items }
return templates . TemplateResponse ( " build/_new_deck_multicopy.html " , ctx )
except Exception :
return HTMLResponse ( " " )
2025-10-03 10:43:24 -07:00
@router.post ( " /themes/add " , response_class = HTMLResponse )
async def build_theme_add ( request : Request , theme : str = Form ( " " ) ) - > HTMLResponse :
if not ENABLE_CUSTOM_THEMES :
return HTMLResponse ( " " , status_code = 204 )
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
trimmed = theme . strip ( )
sanitized = _sanitize_theme ( trimmed ) if trimmed else " "
if trimmed and not sanitized :
ctx = _custom_theme_context ( request , sess , message = _INVALID_THEME_MESSAGE , level = " error " )
else :
value = sanitized if sanitized is not None else trimmed
_ , message , level = theme_mgr . add_theme (
sess ,
value ,
commander_tags = list ( sess . get ( " tags " , [ ] ) ) ,
mode = sess . get ( " theme_match_mode " , DEFAULT_THEME_MATCH_MODE ) ,
limit = USER_THEME_LIMIT ,
)
ctx = _custom_theme_context ( request , sess , message = message , level = level )
resp = templates . TemplateResponse ( " build/_new_deck_additional_themes.html " , ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
@router.post ( " /themes/remove " , response_class = HTMLResponse )
async def build_theme_remove ( request : Request , theme : str = Form ( " " ) ) - > HTMLResponse :
if not ENABLE_CUSTOM_THEMES :
return HTMLResponse ( " " , status_code = 204 )
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
value = _sanitize_theme ( theme ) or theme
_ , message , level = theme_mgr . remove_theme (
sess ,
value ,
commander_tags = list ( sess . get ( " tags " , [ ] ) ) ,
mode = sess . get ( " theme_match_mode " , DEFAULT_THEME_MATCH_MODE ) ,
)
ctx = _custom_theme_context ( request , sess , message = message , level = level )
resp = templates . TemplateResponse ( " build/_new_deck_additional_themes.html " , ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
@router.post ( " /themes/choose " , response_class = HTMLResponse )
async def build_theme_choose (
request : Request ,
original : str = Form ( " " ) ,
choice : str = Form ( " " ) ,
) - > HTMLResponse :
if not ENABLE_CUSTOM_THEMES :
return HTMLResponse ( " " , status_code = 204 )
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
selection = _sanitize_theme ( choice ) or choice
_ , message , level = theme_mgr . choose_suggestion (
sess ,
original ,
selection ,
commander_tags = list ( sess . get ( " tags " , [ ] ) ) ,
mode = sess . get ( " theme_match_mode " , DEFAULT_THEME_MATCH_MODE ) ,
)
ctx = _custom_theme_context ( request , sess , message = message , level = level )
resp = templates . TemplateResponse ( " build/_new_deck_additional_themes.html " , ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
@router.post ( " /themes/mode " , response_class = HTMLResponse )
async def build_theme_mode ( request : Request , mode : str = Form ( " permissive " ) ) - > HTMLResponse :
if not ENABLE_CUSTOM_THEMES :
return HTMLResponse ( " " , status_code = 204 )
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
_ , message , level = theme_mgr . set_mode (
sess ,
mode ,
commander_tags = list ( sess . get ( " tags " , [ ] ) ) ,
)
ctx = _custom_theme_context ( request , sess , message = message , level = level )
resp = templates . TemplateResponse ( " build/_new_deck_additional_themes.html " , ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
2025-10-14 16:09:58 -07:00
@router.post ( " /new/toggle-skip " , response_class = JSONResponse )
async def build_new_toggle_skip (
request : Request ,
skip_key : str = Form ( . . . ) ,
enabled : str = Form ( . . . ) ,
) - > JSONResponse :
""" Toggle a skip configuration flag (wizard-only, before build starts).
Enforces mutual exclusivity :
- skip_lands and skip_to_misc are mutually exclusive with individual land flags
- Individual land flags are mutually exclusive with each other
"""
sid = request . cookies . get ( " sid " ) or request . headers . get ( " X-Session-ID " )
if not sid :
return JSONResponse ( { " error " : " No session ID " } , status_code = 400 )
sess = get_session ( sid )
# Wizard-only: reject if build has started
if " build_ctx " in sess :
return JSONResponse ( { " error " : " Cannot modify skip settings after build has started " } , status_code = 400 )
# Validate skip_key
valid_keys = {
" skip_lands " , " skip_to_misc " , " skip_basics " , " skip_staples " ,
" skip_kindred " , " skip_fetches " , " skip_duals " , " skip_triomes " ,
" skip_all_creatures " ,
" skip_creature_primary " , " skip_creature_secondary " , " skip_creature_fill " ,
" skip_all_spells " ,
" skip_ramp " , " skip_removal " , " skip_wipes " , " skip_card_advantage " ,
" skip_protection " , " skip_spell_fill " ,
" skip_post_adjust "
}
if skip_key not in valid_keys :
return JSONResponse ( { " error " : f " Invalid skip key: { skip_key } " } , status_code = 400 )
# Parse enabled flag
enabled_flag = str ( enabled ) . strip ( ) . lower ( ) in { " 1 " , " true " , " yes " , " on " }
# Mutual exclusivity rules
land_group_flags = { " skip_lands " , " skip_to_misc " }
individual_land_flags = { " skip_basics " , " skip_staples " , " skip_kindred " , " skip_fetches " , " skip_duals " , " skip_triomes " }
creature_specific_flags = { " skip_creature_primary " , " skip_creature_secondary " , " skip_creature_fill " }
spell_specific_flags = { " skip_ramp " , " skip_removal " , " skip_wipes " , " skip_card_advantage " , " skip_protection " , " skip_spell_fill " }
# If enabling a flag, check for conflicts
if enabled_flag :
# Rule 1: skip_lands/skip_to_misc disables all individual land flags
if skip_key in land_group_flags :
for key in individual_land_flags :
sess [ key ] = False
# Rule 2: Individual land flags disable skip_lands/skip_to_misc
elif skip_key in individual_land_flags :
for key in land_group_flags :
sess [ key ] = False
# Rule 3: skip_all_creatures disables specific creature flags
elif skip_key == " skip_all_creatures " :
for key in creature_specific_flags :
sess [ key ] = False
# Rule 4: Specific creature flags disable skip_all_creatures
elif skip_key in creature_specific_flags :
sess [ " skip_all_creatures " ] = False
# Rule 5: skip_all_spells disables specific spell flags
elif skip_key == " skip_all_spells " :
for key in spell_specific_flags :
sess [ key ] = False
# Rule 6: Specific spell flags disable skip_all_spells
elif skip_key in spell_specific_flags :
sess [ " skip_all_spells " ] = False
# Set the requested flag
sess [ skip_key ] = enabled_flag
# Auto-enable skip_post_adjust when any other skip is enabled
if enabled_flag and skip_key != " skip_post_adjust " :
sess [ " skip_post_adjust " ] = True
# Auto-disable skip_post_adjust when all other skips are disabled
if not enabled_flag :
any_other_skip = any (
sess . get ( k , False ) for k in valid_keys
if k != " skip_post_adjust " and k != skip_key
)
if not any_other_skip :
sess [ " skip_post_adjust " ] = False
return JSONResponse ( {
" success " : True ,
" skip_key " : skip_key ,
" enabled " : enabled_flag ,
" skip_post_adjust " : bool ( sess . get ( " skip_post_adjust " , False ) )
} )
def _get_descriptive_stage_label ( stage : Dict [ str , Any ] , ctx : Dict [ str , Any ] ) - > str :
""" Generate a more descriptive label for Quick Build progress display. """
key = stage . get ( " key " , " " )
base_label = stage . get ( " label " , " " )
# Land stages - show what type of lands
land_types = {
" land1 " : " Basics " ,
" land2 " : " Staples " ,
" land3 " : " Fetches " ,
" land4 " : " Duals " ,
" land5 " : " Triomes " ,
" land6 " : " Kindred " ,
" land7 " : " Misc Utility " ,
" land8 " : " Final Lands "
}
if key in land_types :
return f " Lands: { land_types [ key ] } "
# Creature stages - show associated theme
if " creatures " in key :
tags = ctx . get ( " tags " , [ ] )
if key == " creatures_all_theme " :
if tags :
all_tags = " + " . join ( tags [ : 3 ] ) # Show up to 3 tags
return f " Creatures: All Themes ( { all_tags } ) "
return " Creatures: All Themes "
elif key == " creatures_primary " and len ( tags ) > = 1 :
return f " Creatures: { tags [ 0 ] } "
elif key == " creatures_secondary " and len ( tags ) > = 2 :
return f " Creatures: { tags [ 1 ] } "
elif key == " creatures_tertiary " and len ( tags ) > = 3 :
return f " Creatures: { tags [ 2 ] } "
# Let creatures_fill use default "Creatures: Fill" label
# Theme spell fill stage - adds any card type (artifacts, enchantments, instants, etc.) that fits theme
if key == " spells_fill " :
return " Theme Spell Fill "
# Default: return original label
return base_label
def _run_quick_build_stages ( sid : str ) :
""" Background task: Run all stages for Quick Build and update progress in session. """
import logging
logger = logging . getLogger ( __name__ )
logger . info ( f " [Quick Build] Starting background task for sid= { sid } " )
sess = get_session ( sid )
logger . info ( f " [Quick Build] Retrieved session: { sess is not None } " )
ctx = sess . get ( " build_ctx " )
if not ctx :
logger . error ( f " [Quick Build] No build_ctx found in session " )
sess [ " quick_build_progress " ] = {
" running " : False ,
" current_stage " : " Error: No build context " ,
" completed_stages " : [ ]
}
return
logger . info ( f " [Quick Build] build_ctx found with { len ( ctx . get ( ' stages ' , [ ] ) ) } stages " )
# CRITICAL: Inject session reference into context so skip config can be read
ctx [ " session " ] = sess
logger . info ( " [Quick Build] Injected session reference into context " )
stages = ctx . get ( " stages " , [ ] )
res = None
# Initialize progress tracking
sess [ " quick_build_progress " ] = {
" running " : True ,
" current_stage " : " Starting build... "
}
try :
logger . info ( " [Quick Build] Starting stage loop " )
# Track which phase we're in for simplified progress display
current_phase = None
while True :
current_idx = ctx . get ( " idx " , 0 )
if current_idx > = len ( stages ) :
logger . info ( f " [Quick Build] Reached end of stages (idx= { current_idx } ) " )
break
current_stage = stages [ current_idx ]
stage_key = current_stage . get ( " key " , " " )
logger . info ( f " [Quick Build] Stage { current_idx } key: { stage_key } " )
# Determine simplified phase label
if stage_key . startswith ( " creatures " ) :
new_phase = " Adding Creatures "
elif stage_key . startswith ( " spells " ) or stage_key in [ " spells_ramp " , " spells_removal " , " spells_wipes " , " spells_card_advantage " , " spells_protection " , " spells_fill " ] :
new_phase = " Adding Spells "
elif stage_key . startswith ( " land " ) :
new_phase = " Adding Lands "
elif stage_key in [ " post_spell_land_adjust " , " reporting " ] :
new_phase = " Doing Some Final Touches "
else :
new_phase = " Building Deck "
# Only update progress if phase changed
if new_phase != current_phase :
current_phase = new_phase
sess [ " quick_build_progress " ] [ " current_stage " ] = current_phase
logger . info ( f " [Quick Build] Phase: { current_phase } " )
# Run stage with show_skipped=False
res = orch . run_stage ( ctx , rerun = False , show_skipped = False )
logger . info ( f " [Quick Build] Stage { stage_key } completed, done= { res . get ( ' done ' ) } " )
# Handle Multi-Copy package marking
try :
if res . get ( " label " ) == " Multi-Copy Package " and sess . get ( " multi_copy " ) :
mc = sess . get ( " multi_copy " )
sess [ " mc_applied_key " ] = f " { mc . get ( ' id ' , ' ' ) } | { int ( mc . get ( ' count ' , 0 ) ) } | { 1 if mc . get ( ' thrumming ' ) else 0 } "
except Exception :
pass
# Check if build is done (reporting stage marks done=True)
if res . get ( " done " ) :
break
# run_stage() advances ctx["idx"] internally when stage completes successfully
# If stage is gated, it also advances the index, so we just continue the loop
# Show summary generation message (stay here for a moment)
sess [ " quick_build_progress " ] [ " current_stage " ] = " Generating Summary "
import time
time . sleep ( 2 ) # Pause briefly so user sees this stage
# Store final result for polling endpoint
sess [ " last_result " ] = res or { }
sess [ " last_step " ] = 5
# Small delay to show finishing message
import time
time . sleep ( 1.5 )
except Exception as e :
# Store error state
logger . exception ( f " [Quick Build] Error during stage execution: { e } " )
sess [ " quick_build_progress " ] [ " current_stage " ] = f " Error: { str ( e ) } "
finally :
# Mark build as complete
logger . info ( " [Quick Build] Background task completed " )
sess [ " quick_build_progress " ] [ " running " ] = False
sess [ " quick_build_progress " ] [ " current_stage " ] = " Complete "
2025-08-28 14:57:22 -07:00
@router.post ( " /new " , response_class = HTMLResponse )
async def build_new_submit (
request : Request ,
2025-10-14 16:09:58 -07:00
background_tasks : BackgroundTasks ,
2025-08-28 14:57:22 -07:00
name : str = Form ( " " ) ,
commander : str = Form ( . . . ) ,
primary_tag : str | None = Form ( None ) ,
secondary_tag : str | None = Form ( None ) ,
tertiary_tag : str | None = Form ( None ) ,
tag_mode : str | None = Form ( " AND " ) ,
2025-10-06 09:17:59 -07:00
partner_enabled : str | None = Form ( None ) ,
secondary_commander : str | None = Form ( None ) ,
background : str | None = Form ( None ) ,
partner_auto_opt_out : str | None = Form ( None ) ,
partner_selection_source : str | None = Form ( None ) ,
2025-08-28 14:57:22 -07:00
bracket : int = Form ( . . . ) ,
ramp : int = Form ( None ) ,
lands : int = Form ( None ) ,
basic_lands : int = Form ( None ) ,
creatures : int = Form ( None ) ,
removal : int = Form ( None ) ,
wipes : int = Form ( None ) ,
card_advantage : int = Form ( None ) ,
protection : int = Form ( None ) ,
2025-09-01 16:55:24 -07:00
prefer_combos : bool = Form ( False ) ,
combo_count : int | None = Form ( None ) ,
combo_balance : str | None = Form ( None ) ,
2025-09-02 16:03:12 -07:00
enable_multicopy : bool = Form ( False ) ,
2025-10-02 15:31:05 -07:00
use_owned_only : bool = Form ( False ) ,
prefer_owned : bool = Form ( False ) ,
swap_mdfc_basics : bool = Form ( False ) ,
2025-09-02 16:03:12 -07:00
# Integrated Multi-Copy (optional)
multi_choice_id : str | None = Form ( None ) ,
multi_count : int | None = Form ( None ) ,
multi_thrumming : str | None = Form ( None ) ,
2025-09-09 09:36:17 -07:00
# Must-haves/excludes (optional)
2025-09-09 18:15:30 -07:00
include_cards : str = Form ( " " ) ,
2025-09-09 09:36:17 -07:00
exclude_cards : str = Form ( " " ) ,
2025-09-09 18:15:30 -07:00
enforcement_mode : str = Form ( " warn " ) ,
allow_illegal : bool = Form ( False ) ,
fuzzy_matching : bool = Form ( True ) ,
2025-10-20 18:29:53 -07:00
# Build count for multi-build
build_count : int = Form ( 1 ) ,
2025-10-14 16:09:58 -07:00
# Quick Build flag
quick_build : str | None = Form ( None ) ,
2025-08-28 14:57:22 -07:00
) - > HTMLResponse :
""" Handle New Deck modal submit and immediately start the build (skip separate review page). """
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-10-06 09:17:59 -07:00
partner_feature_enabled = ENABLE_PARTNER_MECHANICS
raw_partner_flag = ( partner_enabled or " " ) . strip ( ) . lower ( )
partner_checkbox = partner_feature_enabled and raw_partner_flag in { " 1 " , " true " , " on " , " yes " }
initial_secondary = ( secondary_commander or " " ) . strip ( )
initial_background = ( background or " " ) . strip ( )
auto_opt_out_flag = ( partner_auto_opt_out or " " ) . strip ( ) . lower ( ) in { " 1 " , " true " , " on " , " yes " }
partner_form_state : dict [ str , Any ] = {
" partner_enabled " : bool ( partner_checkbox ) ,
" secondary_commander " : initial_secondary ,
" background " : initial_background ,
" partner_mode " : None ,
" partner_auto_note " : None ,
" partner_warnings " : [ ] ,
" combined_preview " : None ,
" partner_auto_assigned " : False ,
}
2025-10-02 15:31:05 -07:00
def _form_state ( commander_value : str ) - > dict [ str , Any ] :
return {
" name " : name ,
" commander " : commander_value ,
" primary_tag " : primary_tag or " " ,
" secondary_tag " : secondary_tag or " " ,
" tertiary_tag " : tertiary_tag or " " ,
" tag_mode " : tag_mode or " AND " ,
" bracket " : bracket ,
" combo_count " : combo_count ,
" combo_balance " : ( combo_balance or " mix " ) ,
" prefer_combos " : bool ( prefer_combos ) ,
" enable_multicopy " : bool ( enable_multicopy ) ,
" use_owned_only " : bool ( use_owned_only ) ,
" prefer_owned " : bool ( prefer_owned ) ,
" swap_mdfc_basics " : bool ( swap_mdfc_basics ) ,
" 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 ) ,
2025-10-06 09:17:59 -07:00
" partner_enabled " : partner_form_state [ " partner_enabled " ] ,
" secondary_commander " : partner_form_state [ " secondary_commander " ] ,
" background " : partner_form_state [ " background " ] ,
2025-10-02 15:31:05 -07:00
}
commander_detail = lookup_commander_detail ( commander )
if commander_detail :
eligible_raw = commander_detail . get ( " eligible_faces " )
eligible_faces = [ str ( face ) . strip ( ) for face in eligible_raw or [ ] if str ( face ) . strip ( ) ] if isinstance ( eligible_raw , list ) else [ ]
if eligible_faces :
norm_input = str ( commander ) . strip ( ) . casefold ( )
eligible_norms = [ face . casefold ( ) for face in eligible_faces ]
if norm_input not in eligible_norms :
suggested = eligible_faces [ 0 ]
primary_face = str ( commander_detail . get ( " primary_face " ) or commander_detail . get ( " name " ) or commander ) . strip ( )
faces_str = " , " . join ( f " ' { face } ' " for face in eligible_faces )
error_msg = (
f " ' { primary_face or commander } ' can ' t lead a deck. Use { faces_str } as the commander instead. "
" We ' ve updated the commander field for you. "
)
ctx = {
" request " : request ,
" error " : error_msg ,
" brackets " : orch . bracket_options ( ) ,
" labels " : orch . ideal_labels ( ) ,
" defaults " : orch . ideal_defaults ( ) ,
" allow_must_haves " : ALLOW_MUST_HAVES ,
2025-10-07 15:56:57 -07:00
" show_must_have_buttons " : SHOW_MUST_HAVE_BUTTONS ,
2025-10-03 10:43:24 -07:00
" enable_custom_themes " : ENABLE_CUSTOM_THEMES ,
2025-10-20 18:29:53 -07:00
" enable_batch_build " : ENABLE_BATCH_BUILD ,
2025-10-02 15:31:05 -07:00
" form " : _form_state ( suggested ) ,
2025-10-06 09:17:59 -07:00
" tag_slot_html " : None ,
2025-10-02 15:31:05 -07:00
}
2025-10-03 10:43:24 -07:00
theme_ctx = _custom_theme_context ( request , sess , message = error_msg , level = " error " )
for key , value in theme_ctx . items ( ) :
if key == " request " :
continue
ctx [ key ] = value
2025-10-02 15:31:05 -07:00
resp = templates . TemplateResponse ( " build/_new_deck_modal.html " , ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
2025-08-28 14:57:22 -07:00
# Normalize and validate commander selection (best-effort via orchestrator)
sel = orch . commander_select ( commander )
if not sel . get ( " ok " ) :
# Re-render modal with error
ctx = {
" request " : request ,
" error " : sel . get ( " error " , " Commander not found " ) ,
" brackets " : orch . bracket_options ( ) ,
" labels " : orch . ideal_labels ( ) ,
" defaults " : orch . ideal_defaults ( ) ,
2025-09-09 09:36:17 -07:00
" allow_must_haves " : ALLOW_MUST_HAVES , # Add feature flag
2025-10-07 15:56:57 -07:00
" show_must_have_buttons " : SHOW_MUST_HAVE_BUTTONS ,
2025-10-03 10:43:24 -07:00
" enable_custom_themes " : ENABLE_CUSTOM_THEMES ,
2025-10-20 18:29:53 -07:00
" enable_batch_build " : ENABLE_BATCH_BUILD ,
2025-10-02 15:31:05 -07:00
" form " : _form_state ( commander ) ,
2025-10-06 09:17:59 -07:00
" tag_slot_html " : None ,
2025-08-28 14:57:22 -07:00
}
2025-10-03 10:43:24 -07:00
theme_ctx = _custom_theme_context ( request , sess , message = ctx [ " error " ] , level = " error " )
for key , value in theme_ctx . items ( ) :
if key == " request " :
continue
ctx [ key ] = value
2025-08-28 14:57:22 -07:00
resp = templates . TemplateResponse ( " build/_new_deck_modal.html " , ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
2025-10-06 09:17:59 -07:00
primary_commander_name = sel . get ( " name " ) or commander
2025-09-03 18:00:06 -07:00
# Enforce GC bracket restriction before saving session (silently coerce to 3)
try :
2025-10-06 09:17:59 -07:00
is_gc = bool ( primary_commander_name in getattr ( bc , ' GAME_CHANGERS ' , [ ] ) )
2025-09-03 18:00:06 -07:00
except Exception :
is_gc = False
if is_gc :
try :
if int ( bracket ) < 3 :
bracket = 3
except Exception :
bracket = 3
2025-08-28 14:57:22 -07:00
# Save to session
2025-10-06 09:17:59 -07:00
sess [ " commander " ] = primary_commander_name
(
partner_error ,
combined_payload ,
partner_warnings ,
partner_auto_note ,
resolved_secondary ,
resolved_background ,
partner_mode ,
partner_auto_assigned_flag ,
) = _resolve_partner_selection (
primary_commander_name ,
feature_enabled = partner_feature_enabled ,
partner_enabled = partner_checkbox ,
secondary_candidate = secondary_commander ,
background_candidate = background ,
auto_opt_out = auto_opt_out_flag ,
selection_source = partner_selection_source ,
)
partner_form_state [ " partner_mode " ] = partner_mode
partner_form_state [ " partner_auto_note " ] = partner_auto_note
partner_form_state [ " partner_warnings " ] = partner_warnings
partner_form_state [ " combined_preview " ] = combined_payload
if resolved_secondary :
partner_form_state [ " secondary_commander " ] = resolved_secondary
if resolved_background :
partner_form_state [ " background " ] = resolved_background
partner_form_state [ " partner_auto_assigned " ] = bool ( partner_auto_assigned_flag )
combined_theme_pool : list [ str ] = [ ]
if isinstance ( combined_payload , dict ) :
raw_tags = combined_payload . get ( " theme_tags " ) or [ ]
for tag in raw_tags :
token = str ( tag ) . strip ( )
if not token :
continue
if token not in combined_theme_pool :
combined_theme_pool . append ( token )
if partner_error :
available_tags = orch . tags_for_commander ( primary_commander_name )
recommended_tags = orch . recommended_tags_for_commander ( primary_commander_name )
recommended_reasons = orch . recommended_tag_reasons_for_commander ( primary_commander_name )
inspect_ctx : dict [ str , Any ] = {
" request " : request ,
" commander " : { " name " : primary_commander_name , " exclusion " : lookup_commander_detail ( primary_commander_name ) } ,
" tags " : available_tags ,
" recommended " : recommended_tags ,
" recommended_reasons " : recommended_reasons ,
" gc_commander " : is_gc ,
" brackets " : orch . bracket_options ( ) ,
}
inspect_ctx . update (
_partner_ui_context (
primary_commander_name ,
partner_enabled = partner_checkbox ,
secondary_selection = partner_form_state [ " secondary_commander " ] or None ,
background_selection = partner_form_state [ " background " ] or None ,
combined_preview = combined_payload ,
warnings = partner_warnings ,
partner_error = partner_error ,
auto_note = partner_auto_note ,
auto_assigned = partner_form_state [ " partner_auto_assigned " ] ,
auto_prefill_allowed = not auto_opt_out_flag ,
)
)
partner_tags = inspect_ctx . pop ( " partner_theme_tags " , None )
if partner_tags :
inspect_ctx [ " tags " ] = partner_tags
tag_slot_html = templates . get_template ( " build/_new_deck_tags.html " ) . render ( inspect_ctx )
ctx = {
" request " : request ,
" error " : partner_error ,
" brackets " : orch . bracket_options ( ) ,
" labels " : orch . ideal_labels ( ) ,
" defaults " : orch . ideal_defaults ( ) ,
" allow_must_haves " : ALLOW_MUST_HAVES ,
2025-10-07 15:56:57 -07:00
" show_must_have_buttons " : SHOW_MUST_HAVE_BUTTONS ,
2025-10-06 09:17:59 -07:00
" enable_custom_themes " : ENABLE_CUSTOM_THEMES ,
2025-10-20 18:29:53 -07:00
" enable_batch_build " : ENABLE_BATCH_BUILD ,
2025-10-06 09:17:59 -07:00
" form " : _form_state ( primary_commander_name ) ,
" tag_slot_html " : tag_slot_html ,
}
theme_ctx = _custom_theme_context ( request , sess , message = partner_error , level = " error " )
for key , value in theme_ctx . items ( ) :
if key == " request " :
continue
ctx [ key ] = value
resp = templates . TemplateResponse ( " build/_new_deck_modal.html " , ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
if partner_checkbox and combined_payload :
sess [ " partner_enabled " ] = True
if resolved_secondary :
sess [ " secondary_commander " ] = resolved_secondary
else :
sess . pop ( " secondary_commander " , None )
if resolved_background :
sess [ " background " ] = resolved_background
else :
sess . pop ( " background " , None )
if partner_mode :
sess [ " partner_mode " ] = partner_mode
else :
sess . pop ( " partner_mode " , None )
sess [ " combined_commander " ] = combined_payload
sess [ " partner_warnings " ] = partner_warnings
if partner_auto_note :
sess [ " partner_auto_note " ] = partner_auto_note
else :
sess . pop ( " partner_auto_note " , None )
sess [ " partner_auto_assigned " ] = bool ( partner_auto_assigned_flag )
sess [ " partner_auto_opt_out " ] = bool ( auto_opt_out_flag )
else :
sess [ " partner_enabled " ] = False
for key in [
" secondary_commander " ,
" background " ,
" partner_mode " ,
" partner_warnings " ,
" combined_commander " ,
" partner_auto_note " ,
] :
try :
sess . pop ( key )
except KeyError :
pass
for key in [ " partner_auto_assigned " , " partner_auto_opt_out " ] :
try :
sess . pop ( key )
except KeyError :
pass
2025-10-03 10:43:24 -07:00
# 1) Start from explicitly selected tags (order preserved)
2025-08-28 14:57:22 -07:00
tags = [ t for t in [ primary_tag , secondary_tag , tertiary_tag ] if t ]
2025-10-03 10:43:24 -07:00
user_explicit = bool ( tags ) # whether the user set any theme in the form
# 2) Consider user-added supplemental themes from the Additional Themes UI
additional_from_session = [ ]
try :
# custom_theme_manager stores resolved list here on add/resolve; present before submit
additional_from_session = [
str ( x ) for x in ( sess . get ( " additional_themes " ) or [ ] ) if isinstance ( x , str ) and x . strip ( )
]
except Exception :
additional_from_session = [ ]
# 3) If no explicit themes were selected, prefer additional themes as primary/secondary/tertiary
if not user_explicit and additional_from_session :
# Cap to three and preserve order
tags = list ( additional_from_session [ : 3 ] )
# 4) If user selected some themes, fill remaining slots with additional themes (deduping)
elif user_explicit and additional_from_session :
seen = { str ( t ) . strip ( ) . casefold ( ) for t in tags }
for name in additional_from_session :
key = name . strip ( ) . casefold ( )
if key in seen :
continue
tags . append ( name )
seen . add ( key )
if len ( tags ) > = 3 :
break
# 5) If still empty (no explicit and no additional), fall back to commander-recommended default
2025-08-28 14:57:22 -07:00
if not tags :
2025-10-06 09:17:59 -07:00
if combined_theme_pool :
tags = combined_theme_pool [ : 3 ]
else :
try :
rec = orch . recommended_tags_for_commander ( sess [ " commander " ] ) or [ ]
if rec :
tags = [ rec [ 0 ] ]
except Exception :
pass
2025-08-28 14:57:22 -07:00
sess [ " tags " ] = tags
sess [ " tag_mode " ] = ( tag_mode or " AND " ) . upper ( )
try :
# Default to bracket 3 (Upgraded) when not provided
sess [ " bracket " ] = int ( bracket ) if ( bracket is not None ) else 3
except Exception :
try :
sess [ " bracket " ] = int ( bracket )
except Exception :
sess [ " bracket " ] = 3
# Ideals: use provided values if any, else defaults
ideals = orch . ideal_defaults ( )
overrides = { k : v for k , v in {
" ramp " : ramp ,
" lands " : lands ,
" basic_lands " : basic_lands ,
" creatures " : creatures ,
" removal " : removal ,
" wipes " : wipes ,
" card_advantage " : card_advantage ,
" protection " : protection ,
} . items ( ) if v is not None }
for k , v in overrides . items ( ) :
try :
ideals [ k ] = int ( v )
except Exception :
pass
sess [ " ideals " ] = ideals
2025-10-03 10:43:24 -07:00
if ENABLE_CUSTOM_THEMES :
try :
theme_mgr . refresh_resolution (
sess ,
commander_tags = tags ,
mode = sess . get ( " theme_match_mode " , DEFAULT_THEME_MATCH_MODE ) ,
)
except ValueError as exc :
error_msg = str ( exc )
ctx = {
" request " : request ,
" error " : error_msg ,
" brackets " : orch . bracket_options ( ) ,
" labels " : orch . ideal_labels ( ) ,
" defaults " : orch . ideal_defaults ( ) ,
" allow_must_haves " : ALLOW_MUST_HAVES ,
2025-10-07 15:56:57 -07:00
" show_must_have_buttons " : SHOW_MUST_HAVE_BUTTONS ,
2025-10-03 10:43:24 -07:00
" enable_custom_themes " : ENABLE_CUSTOM_THEMES ,
2025-10-20 18:29:53 -07:00
" enable_batch_build " : ENABLE_BATCH_BUILD ,
2025-10-03 10:43:24 -07:00
" form " : _form_state ( sess . get ( " commander " , " " ) ) ,
2025-10-06 09:17:59 -07:00
" tag_slot_html " : None ,
2025-10-03 10:43:24 -07:00
}
theme_ctx = _custom_theme_context ( request , sess , message = error_msg , level = " error " )
for key , value in theme_ctx . items ( ) :
if key == " request " :
continue
ctx [ key ] = value
resp = templates . TemplateResponse ( " build/_new_deck_modal.html " , ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
2025-09-01 16:55:24 -07:00
# Persist preferences
try :
sess [ " prefer_combos " ] = bool ( prefer_combos )
except Exception :
sess [ " prefer_combos " ] = False
2025-10-02 15:31:05 -07:00
try :
sess [ " use_owned_only " ] = bool ( use_owned_only )
except Exception :
sess [ " use_owned_only " ] = False
try :
sess [ " prefer_owned " ] = bool ( prefer_owned )
except Exception :
sess [ " prefer_owned " ] = False
try :
sess [ " swap_mdfc_basics " ] = bool ( swap_mdfc_basics )
except Exception :
sess [ " swap_mdfc_basics " ] = False
2025-09-01 16:55:24 -07:00
# Combos config from modal
try :
if combo_count is not None :
sess [ " combo_target_count " ] = max ( 0 , min ( 10 , int ( combo_count ) ) )
except Exception :
pass
try :
if combo_balance :
bval = str ( combo_balance ) . strip ( ) . lower ( )
if bval in ( " early " , " late " , " mix " ) :
sess [ " combo_balance " ] = bval
except Exception :
pass
2025-09-02 16:03:12 -07:00
# Multi-Copy selection from modal (opt-in)
try :
# Clear any prior selection first; this flow should define it explicitly when present
if " multi_copy " in sess :
del sess [ " multi_copy " ]
if enable_multicopy and multi_choice_id and str ( multi_choice_id ) . strip ( ) :
meta = bc . MULTI_COPY_ARCHETYPES . get ( str ( multi_choice_id ) , { } )
printed_cap = meta . get ( " printed_cap " )
cnt : int
if multi_count is None :
cnt = int ( meta . get ( " default_count " , 25 ) )
else :
try :
cnt = int ( multi_count )
except Exception :
cnt = int ( meta . get ( " default_count " , 25 ) )
if isinstance ( printed_cap , int ) and printed_cap > 0 :
cnt = max ( 1 , min ( printed_cap , cnt ) )
sess [ " multi_copy " ] = {
" id " : str ( multi_choice_id ) ,
" name " : meta . get ( " name " ) or str ( multi_choice_id ) ,
" count " : int ( cnt ) ,
" thrumming " : True if ( multi_thrumming and str ( multi_thrumming ) . strip ( ) in ( " 1 " , " true " , " on " , " yes " ) ) else False ,
}
else :
# Ensure disabled when not opted-in
if " multi_copy " in sess :
del sess [ " multi_copy " ]
# Reset the applied marker so the run can account for the new selection
if " mc_applied_key " in sess :
del sess [ " mc_applied_key " ]
except Exception :
pass
2025-09-09 09:36:17 -07:00
2025-09-09 18:15:30 -07:00
# Process include/exclude cards (M3: Phase 2 - Full Include/Exclude)
2025-09-09 09:36:17 -07:00
try :
from deck_builder . include_exclude_utils import parse_card_list_input , IncludeExcludeDiagnostics
2025-09-09 18:15:30 -07:00
# Clear any old include/exclude data
for k in [ " include_cards " , " exclude_cards " , " include_exclude_diagnostics " , " enforcement_mode " , " allow_illegal " , " fuzzy_matching " ] :
2025-09-09 09:36:17 -07:00
if k in sess :
del sess [ k ]
2025-09-09 18:15:30 -07:00
# 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
2025-09-09 09:36:17 -07:00
if exclude_cards and exclude_cards . strip ( ) :
2025-09-09 18:15:30 -07:00
print ( f " DEBUG: Raw exclude_cards input: ' { exclude_cards } ' " )
2025-09-09 09:36:17 -07:00
exclude_list = parse_card_list_input ( exclude_cards . strip ( ) )
2025-09-09 18:15:30 -07:00
print ( f " DEBUG: Parsed exclude_list: { exclude_list } " )
2025-09-09 09:36:17 -07:00
sess [ " exclude_cards " ] = exclude_list
2025-09-09 18:15:30 -07:00
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 ( ) ) :
2025-09-09 09:36:17 -07:00
diagnostics = IncludeExcludeDiagnostics (
missing_includes = [ ] ,
ignored_color_identity = [ ] ,
illegal_dropped = [ ] ,
illegal_allowed = [ ] ,
2025-09-09 18:15:30 -07:00
excluded_removed = sess . get ( " exclude_cards " , [ ] ) ,
2025-09-09 09:36:17 -07:00
duplicates_collapsed = { } ,
include_added = [ ] ,
include_over_ideal = { } ,
fuzzy_corrections = { } ,
confirmation_needed = [ ] ,
2025-09-09 18:15:30 -07:00
list_size_warnings = {
" includes_count " : len ( sess . get ( " include_cards " , [ ] ) ) ,
" excludes_count " : len ( sess . get ( " exclude_cards " , [ ] ) ) ,
" includes_limit " : 10 ,
" excludes_limit " : 15
}
2025-09-09 09:36:17 -07:00
)
2025-09-09 18:15:30 -07:00
sess [ " include_exclude_diagnostics " ] = diagnostics . __dict__
2025-09-09 09:36:17 -07:00
except Exception as e :
# If exclude parsing fails, log but don't block the build
import logging
logging . warning ( f " Failed to parse exclude cards: { e } " )
2025-08-28 14:57:22 -07:00
# Clear any old staged build context
for k in [ " build_ctx " , " locks " , " replace_mode " ] :
if k in sess :
try :
del sess [ k ]
except Exception :
pass
2025-09-02 16:03:12 -07:00
# Reset multi-copy suggestion debounce for a fresh run (keep selected choice)
if " mc_seen_keys " in sess :
try :
del sess [ " mc_seen_keys " ]
except Exception :
pass
2025-08-28 14:57:22 -07:00
# Persist optional custom export base name
if isinstance ( name , str ) and name . strip ( ) :
sess [ " custom_export_base " ] = name . strip ( )
else :
if " custom_export_base " in sess :
try :
del sess [ " custom_export_base " ]
except Exception :
pass
2025-09-02 11:39:14 -07:00
# If setup/tagging is not ready or stale, show a modal prompt instead of auto-running.
try :
if not _is_setup_ready ( ) :
return templates . TemplateResponse (
" build/_setup_prompt_modal.html " ,
{
" request " : request ,
" title " : " Setup required " ,
" message " : " The card database and tags need to be prepared before building a deck. " ,
" action_url " : " /setup/running?start=1&next=/build " ,
" action_label " : " Run Setup " ,
} ,
)
if _is_setup_stale ( ) :
return templates . TemplateResponse (
" build/_setup_prompt_modal.html " ,
{
" request " : request ,
" title " : " Data refresh recommended " ,
" message " : " Your card database is stale. Refreshing ensures up-to-date results. " ,
" action_url " : " /setup/running?start=1&force=1&next=/build " ,
" action_label " : " Refresh Now " ,
} ,
)
except Exception :
# If readiness check fails, continue and let downstream handling surface errors
pass
2025-08-28 14:57:22 -07:00
# Immediately initialize a build context and run the first stage, like hitting Build Deck on review
if " replace_mode " not in sess :
sess [ " replace_mode " ] = True
2025-09-02 11:39:14 -07:00
# Centralized staged context creation
sess [ " build_ctx " ] = start_ctx_from_session ( sess )
2025-10-14 16:09:58 -07:00
2025-10-20 18:29:53 -07:00
# Validate and normalize build_count
try :
build_count = max ( 1 , min ( 10 , int ( build_count ) ) )
except Exception :
build_count = 1
# Check if this is a multi-build request (build_count > 1)
if build_count > 1 :
# Multi-Build: Queue parallel builds and return batch progress page
from . . services . multi_build_orchestrator import queue_builds , run_batch_async
# Create config dict from session for batch builds
batch_config = {
" commander " : sess . get ( " commander " ) ,
" tags " : sess . get ( " tags " , [ ] ) ,
" tag_mode " : sess . get ( " tag_mode " , " AND " ) ,
" bracket " : sess . get ( " bracket " , 3 ) ,
" ideals " : sess . get ( " ideals " , { } ) ,
" prefer_combos " : sess . get ( " prefer_combos " , False ) ,
" combo_target_count " : sess . get ( " combo_target_count " ) ,
" combo_balance " : sess . get ( " combo_balance " ) ,
" multi_copy " : sess . get ( " multi_copy " ) ,
" use_owned_only " : sess . get ( " use_owned_only " , False ) ,
" prefer_owned " : sess . get ( " prefer_owned " , False ) ,
" swap_mdfc_basics " : sess . get ( " swap_mdfc_basics " , False ) ,
" include_cards " : sess . get ( " include_cards " , [ ] ) ,
" exclude_cards " : sess . get ( " exclude_cards " , [ ] ) ,
" enforcement_mode " : sess . get ( " enforcement_mode " , " warn " ) ,
" allow_illegal " : sess . get ( " allow_illegal " , False ) ,
" fuzzy_matching " : sess . get ( " fuzzy_matching " , True ) ,
" locks " : list ( sess . get ( " locks " , [ ] ) ) ,
}
# Handle partner mechanics if present
if sess . get ( " partner_enabled " ) :
batch_config [ " partner_enabled " ] = True
if sess . get ( " secondary_commander " ) :
batch_config [ " secondary_commander " ] = sess [ " secondary_commander " ]
if sess . get ( " background " ) :
batch_config [ " background " ] = sess [ " background " ]
if sess . get ( " partner_mode " ) :
batch_config [ " partner_mode " ] = sess [ " partner_mode " ]
if sess . get ( " combined_commander " ) :
batch_config [ " combined_commander " ] = sess [ " combined_commander " ]
# Add color identity for synergy builder (needed for basic land allocation)
try :
tmp_builder = DeckBuilder ( output_func = lambda * _ : None , input_func = lambda * _ : " " , headless = True )
# Handle partner mechanics if present
if sess . get ( " partner_enabled " ) and sess . get ( " secondary_commander " ) :
from deck_builder . partner_selection import apply_partner_inputs
combined_obj = apply_partner_inputs (
tmp_builder ,
primary_name = sess [ " commander " ] ,
secondary_name = sess . get ( " secondary_commander " ) ,
background_name = sess . get ( " background " ) ,
feature_enabled = True ,
)
if combined_obj and hasattr ( combined_obj , " color_identity " ) :
batch_config [ " colors " ] = list ( combined_obj . color_identity )
else :
# Single commander
df = tmp_builder . load_commander_data ( )
row = df [ df [ " name " ] == sess [ " commander " ] ]
if not row . empty :
# Get colorIdentity from dataframe (it's a string like "RG" or "G")
color_str = row . iloc [ 0 ] . get ( " colorIdentity " , " " )
if color_str :
batch_config [ " colors " ] = list ( color_str ) # Convert "RG" to ['R', 'G']
except Exception as e :
import logging
logging . getLogger ( __name__ ) . warning ( f " [Batch] Failed to load color identity for { sess . get ( ' commander ' ) } : { e } " )
pass # Not critical, synergy builder will skip basics if missing
# Queue the batch
batch_id = queue_builds ( batch_config , build_count , sid )
# Start background task for parallel builds
background_tasks . add_task ( run_batch_async , batch_id , sid )
# Return batch progress template
progress_ctx = {
" request " : request ,
" batch_id " : batch_id ,
" build_count " : build_count ,
" completed " : 0 ,
" current_build " : 1 ,
" status " : " Starting builds... "
}
resp = templates . TemplateResponse ( " build/_batch_progress.html " , progress_ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
# Check if Quick Build was requested (single build only)
2025-10-14 16:09:58 -07:00
is_quick_build = ( quick_build or " " ) . strip ( ) == " 1 "
if is_quick_build :
# Quick Build: Start background task and return progress template immediately
ctx = sess [ " build_ctx " ]
# Initialize progress tracking with dynamic counting (total starts at 0)
sess [ " quick_build_progress " ] = {
" running " : True ,
" total " : 0 ,
" completed " : 0 ,
" current_stage " : " Starting build... "
}
# Start background task to run all stages
background_tasks . add_task ( _run_quick_build_stages , sid )
# Return progress template immediately
progress_ctx = {
" request " : request ,
" progress_pct " : 0 ,
" completed " : 0 ,
" total " : 0 ,
" current_stage " : " Starting build... "
}
resp = templates . TemplateResponse ( " build/_quick_build_progress.html " , progress_ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
else :
# Normal build: Run first stage and wait for user input
res = orch . run_stage ( sess [ " build_ctx " ] , rerun = False , show_skipped = False )
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
try :
if res . get ( " label " ) == " Multi-Copy Package " and sess . get ( " multi_copy " ) :
mc = sess . get ( " multi_copy " )
sess [ " mc_applied_key " ] = f " { mc . get ( ' id ' , ' ' ) } | { int ( mc . get ( ' count ' , 0 ) ) } | { 1 if mc . get ( ' thrumming ' ) else 0 } "
except Exception :
pass
status = " Build complete " if res . get ( " done " ) else " Stage complete "
sess [ " last_step " ] = 5
ctx = step5_ctx_from_result ( request , sess , res , status_text = status , show_skipped = False )
resp = templates . TemplateResponse ( " build/_step5.html " , ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
2025-08-28 14:57:22 -07:00
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
@router.get ( " /step1 " , response_class = HTMLResponse )
async def build_step1 ( request : Request ) - > HTMLResponse :
2025-08-26 20:00:07 -07:00
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
sess [ " last_step " ] = 1
resp = templates . TemplateResponse ( " build/_step1.html " , { " request " : request , " candidates " : [ ] } )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
@router.post ( " /step1 " , response_class = HTMLResponse )
2025-08-26 11:34:42 -07:00
async def build_step1_search (
request : Request ,
query : str = Form ( " " ) ,
auto : str | None = Form ( None ) ,
active : str | None = Form ( None ) ,
) - > HTMLResponse :
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
query = ( query or " " ) . strip ( )
auto_enabled = True if ( auto == " 1 " ) else False
candidates = [ ]
if query :
candidates = orch . commander_candidates ( query , limit = 10 )
# Optional auto-select at a stricter threshold
if auto_enabled and candidates and len ( candidates [ 0 ] ) > = 2 and int ( candidates [ 0 ] [ 1 ] ) > = 98 :
top_name = candidates [ 0 ] [ 0 ]
res = orch . commander_select ( top_name )
if res . get ( " ok " ) :
2025-08-26 20:00:07 -07:00
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
sess [ " last_step " ] = 2
2025-10-06 09:17:59 -07:00
commander_name = res . get ( " name " )
gc_flag = commander_name in getattr ( bc , ' GAME_CHANGERS ' , [ ] )
context = {
" request " : request ,
" commander " : res ,
" tags " : orch . tags_for_commander ( commander_name ) ,
" recommended " : orch . recommended_tags_for_commander ( commander_name ) ,
" recommended_reasons " : orch . recommended_tag_reasons_for_commander ( commander_name ) ,
" brackets " : orch . bracket_options ( ) ,
" gc_commander " : gc_flag ,
" selected_bracket " : ( 3 if gc_flag else None ) ,
" clear_persisted " : True ,
}
context . update (
_partner_ui_context (
commander_name ,
partner_enabled = False ,
secondary_selection = None ,
background_selection = None ,
combined_preview = None ,
warnings = None ,
partner_error = None ,
auto_note = None ,
)
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
)
2025-10-06 09:17:59 -07:00
resp = templates . TemplateResponse ( " build/_step2.html " , context )
2025-08-26 20:00:07 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
sess [ " last_step " ] = 1
resp = templates . TemplateResponse (
2025-08-26 11:34:42 -07:00
" build/_step1.html " ,
{
" request " : request ,
" query " : query ,
" candidates " : candidates ,
" auto " : auto_enabled ,
" active " : active ,
" count " : len ( candidates ) if candidates else 0 ,
} ,
)
2025-08-26 20:00:07 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
@router.post ( " /step1/inspect " , response_class = HTMLResponse )
async def build_step1_inspect ( request : Request , name : str = Form ( . . . ) ) - > HTMLResponse :
2025-08-26 20:00:07 -07:00
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
sess [ " last_step " ] = 1
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
info = orch . commander_inspect ( name )
2025-08-26 20:00:07 -07:00
resp = templates . TemplateResponse (
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
" build/_step1.html " ,
{ " request " : request , " inspect " : info , " selected " : name , " tags " : orch . tags_for_commander ( name ) } ,
)
2025-08-26 20:00:07 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
@router.post ( " /step1/confirm " , response_class = HTMLResponse )
async def build_step1_confirm ( request : Request , name : str = Form ( . . . ) ) - > HTMLResponse :
res = orch . commander_select ( name )
if not res . get ( " ok " ) :
2025-08-26 20:00:07 -07:00
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
sess [ " last_step " ] = 1
resp = templates . TemplateResponse ( " build/_step1.html " , { " request " : request , " error " : res . get ( " error " ) , " selected " : name } )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
2025-08-28 14:57:22 -07:00
# Proceed to step2 placeholder and reset any prior build/session selections
2025-08-26 20:00:07 -07:00
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-08-28 14:57:22 -07:00
# Reset sticky selections from previous runs
2025-10-06 09:17:59 -07:00
for k in [
" tags " ,
" ideals " ,
" bracket " ,
" build_ctx " ,
" last_step " ,
" tag_mode " ,
" mc_seen_keys " ,
" multi_copy " ,
" partner_enabled " ,
" secondary_commander " ,
" background " ,
" partner_mode " ,
" partner_warnings " ,
" combined_commander " ,
" partner_auto_note " ,
] :
2025-08-28 14:57:22 -07:00
try :
if k in sess :
del sess [ k ]
except Exception :
pass
2025-08-26 20:00:07 -07:00
sess [ " last_step " ] = 2
2025-09-03 18:00:06 -07:00
# Determine if commander is a Game Changer to drive bracket UI hiding
is_gc = False
try :
is_gc = bool ( res . get ( " name " ) in getattr ( bc , ' GAME_CHANGERS ' , [ ] ) )
except Exception :
is_gc = False
2025-10-06 09:17:59 -07:00
context = {
" request " : request ,
" commander " : res ,
" tags " : orch . tags_for_commander ( res [ " name " ] ) ,
" recommended " : orch . recommended_tags_for_commander ( res [ " name " ] ) ,
" recommended_reasons " : orch . recommended_tag_reasons_for_commander ( res [ " name " ] ) ,
" brackets " : orch . bracket_options ( ) ,
" gc_commander " : is_gc ,
" selected_bracket " : ( 3 if is_gc else None ) ,
# Signal that this navigation came from a fresh commander confirmation,
# so the Step 2 UI should clear any localStorage theme persistence.
" clear_persisted " : True ,
}
context . update (
_partner_ui_context (
res [ " name " ] ,
partner_enabled = False ,
secondary_selection = None ,
background_selection = None ,
combined_preview = None ,
warnings = None ,
partner_error = None ,
auto_note = None ,
)
2025-08-28 14:57:22 -07:00
)
2025-10-06 09:17:59 -07:00
resp = templates . TemplateResponse ( " build/_step2.html " , context )
2025-08-28 14:57:22 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
@router.post ( " /reset-all " , response_class = HTMLResponse )
async def build_reset_all ( request : Request ) - > HTMLResponse :
""" Clear all build-related session state and return Step 1. """
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
keys = [
" commander " , " tags " , " tag_mode " , " bracket " , " ideals " , " build_ctx " , " last_step " ,
" locks " , " replace_mode "
]
for k in keys :
try :
if k in sess :
del sess [ k ]
except Exception :
pass
sess [ " last_step " ] = 1
resp = templates . TemplateResponse ( " build/_step1.html " , { " request " : request , " candidates " : [ ] } )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
@router.post ( " /step5/rewind " , response_class = HTMLResponse )
async def build_step5_rewind ( request : Request , to : str = Form ( . . . ) ) - > HTMLResponse :
""" Rewind the staged build to a previous visible stage by index or key and show that stage.
Param ` to ` can be an integer index ( 1 - based stage index ) or a stage key string .
"""
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
ctx = sess . get ( " build_ctx " )
if not ctx :
return await build_step5_get ( request )
target_i : int | None = None
# Resolve by numeric index first
try :
idx_val = int ( str ( to ) . strip ( ) )
target_i = idx_val
except Exception :
target_i = None
if target_i is None :
# attempt by key
key = str ( to ) . strip ( )
try :
for h in ctx . get ( " history " , [ ] ) or [ ] :
if str ( h . get ( " key " ) ) == key or str ( h . get ( " label " ) ) == key :
target_i = int ( h . get ( " i " ) )
break
except Exception :
target_i = None
if not target_i :
return await build_step5_get ( request )
# Try to restore snapshot stored for that history entry
try :
hist = ctx . get ( " history " , [ ] ) or [ ]
snap = None
for h in hist :
if int ( h . get ( " i " ) ) == int ( target_i ) :
snap = h . get ( " snapshot " )
break
if snap is not None :
orch . _restore_builder ( ctx [ " builder " ] , snap ) # type: ignore[attr-defined]
ctx [ " idx " ] = int ( target_i ) - 1
ctx [ " last_visible_idx " ] = int ( target_i ) - 1
except Exception :
# As a fallback, restart ctx and run forward until target
2025-09-02 11:39:14 -07:00
sess [ " build_ctx " ] = start_ctx_from_session ( sess )
2025-08-28 14:57:22 -07:00
ctx = sess [ " build_ctx " ]
# Run forward until reaching target
while True :
res = orch . run_stage ( ctx , rerun = False , show_skipped = False )
if int ( res . get ( " idx " , 0 ) ) > = int ( target_i ) :
break
if res . get ( " done " ) :
break
# Finally show the target stage by running it with show_skipped True to get a view
2025-09-02 11:39:14 -07:00
try :
res = orch . run_stage ( ctx , rerun = False , show_skipped = True )
status = " Stage (rewound) " if not res . get ( " done " ) else " Build complete "
ctx_resp = step5_ctx_from_result ( request , sess , res , status_text = status , show_skipped = True , extras = {
2025-08-28 14:57:22 -07:00
" history " : ctx . get ( " history " , [ ] ) ,
2025-09-02 11:39:14 -07:00
} )
except Exception as e :
sess [ " last_step " ] = 5
ctx_resp = step5_error_ctx ( request , sess , f " Failed to rewind: { e } " )
resp = templates . TemplateResponse ( " build/_step5.html " , ctx_resp )
2025-08-26 20:00:07 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : ctx_resp . get ( " summary_token " , 0 ) } } )
2025-08-26 20:00:07 -07:00
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
@router.get ( " /step2 " , response_class = HTMLResponse )
async def build_step2_get ( request : Request ) - > HTMLResponse :
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-08-26 20:00:07 -07:00
sess [ " last_step " ] = 2
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
commander = sess . get ( " commander " )
if not commander :
# Fallback to step1 if no commander in session
2025-08-26 20:00:07 -07:00
resp = templates . TemplateResponse ( " build/_step1.html " , { " request " : request , " candidates " : [ ] } )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
tags = orch . tags_for_commander ( commander )
selected = sess . get ( " tags " , [ ] )
2025-09-03 18:00:06 -07:00
# Determine if the selected commander is considered a Game Changer (affects bracket choices)
is_gc = False
try :
is_gc = bool ( commander in getattr ( bc , ' GAME_CHANGERS ' , [ ] ) )
except Exception :
is_gc = False
# Selected bracket: if GC commander and bracket < 3 or missing, default to 3
sel_br = sess . get ( " bracket " )
try :
sel_br = int ( sel_br ) if sel_br is not None else None
except Exception :
sel_br = None
if is_gc and ( sel_br is None or int ( sel_br ) < 3 ) :
sel_br = 3
2025-10-06 09:17:59 -07:00
partner_enabled = bool ( sess . get ( " partner_enabled " ) and ENABLE_PARTNER_MECHANICS )
2025-10-28 08:21:52 -07:00
import logging
logger = logging . getLogger ( __name__ )
logger . info ( f " Step2 GET: commander= { commander } , partner_enabled= { partner_enabled } , secondary= { sess . get ( ' secondary_commander ' ) } " )
2025-10-06 09:17:59 -07:00
context = {
" request " : request ,
" commander " : { " name " : commander } ,
" tags " : tags ,
" recommended " : orch . recommended_tags_for_commander ( commander ) ,
" recommended_reasons " : orch . recommended_tag_reasons_for_commander ( commander ) ,
" brackets " : orch . bracket_options ( ) ,
" primary_tag " : selected [ 0 ] if len ( selected ) > 0 else " " ,
" secondary_tag " : selected [ 1 ] if len ( selected ) > 1 else " " ,
" tertiary_tag " : selected [ 2 ] if len ( selected ) > 2 else " " ,
" selected_bracket " : sel_br ,
" tag_mode " : sess . get ( " tag_mode " , " AND " ) ,
" gc_commander " : is_gc ,
# If there are no server-side tags for this commander, let the client clear any persisted ones
# to avoid themes sticking between fresh runs.
" clear_persisted " : False if selected else True ,
}
context . update (
_partner_ui_context (
commander ,
partner_enabled = partner_enabled ,
secondary_selection = sess . get ( " secondary_commander " ) if partner_enabled else None ,
background_selection = sess . get ( " background " ) if partner_enabled else None ,
combined_preview = sess . get ( " combined_commander " ) if partner_enabled else None ,
warnings = sess . get ( " partner_warnings " ) if partner_enabled else None ,
partner_error = None ,
auto_note = sess . get ( " partner_auto_note " ) if partner_enabled else None ,
auto_assigned = sess . get ( " partner_auto_assigned " ) if partner_enabled else None ,
auto_prefill_allowed = not bool ( sess . get ( " partner_auto_opt_out " ) ) if partner_enabled else True ,
)
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
)
2025-10-06 09:17:59 -07:00
partner_tags = context . pop ( " partner_theme_tags " , None )
if partner_tags :
2025-10-28 08:21:52 -07:00
import logging
logger = logging . getLogger ( __name__ )
2025-10-06 09:17:59 -07:00
context [ " tags " ] = partner_tags
2025-10-28 08:21:52 -07:00
# Deduplicate recommended tags: remove any that are already in partner_tags
partner_tags_lower = { str ( tag ) . strip ( ) . casefold ( ) for tag in partner_tags }
original_recommended = context . get ( " recommended " , [ ] )
deduplicated_recommended = [
tag for tag in original_recommended
if str ( tag ) . strip ( ) . casefold ( ) not in partner_tags_lower
]
logger . info (
f " Step2: partner_tags= { len ( partner_tags ) } , "
f " original_recommended= { len ( original_recommended ) } , "
f " deduplicated_recommended= { len ( deduplicated_recommended ) } "
)
context [ " recommended " ] = deduplicated_recommended
2025-10-06 09:17:59 -07:00
resp = templates . TemplateResponse ( " build/_step2.html " , context )
2025-08-26 20:00:07 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
@router.post ( " /step2 " , response_class = HTMLResponse )
async def build_step2_submit (
request : Request ,
commander : str = Form ( . . . ) ,
primary_tag : str | None = Form ( None ) ,
secondary_tag : str | None = Form ( None ) ,
tertiary_tag : str | None = Form ( None ) ,
2025-08-26 11:34:42 -07:00
tag_mode : str | None = Form ( " AND " ) ,
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
bracket : int = Form ( . . . ) ,
2025-10-06 09:17:59 -07:00
partner_enabled : str | None = Form ( None ) ,
secondary_commander : str | None = Form ( None ) ,
background : str | None = Form ( None ) ,
partner_selection_source : str | None = Form ( None ) ,
partner_auto_opt_out : str | None = Form ( None ) ,
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
) - > HTMLResponse :
2025-10-06 09:17:59 -07:00
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
sess [ " last_step " ] = 2
partner_feature_enabled = ENABLE_PARTNER_MECHANICS
partner_flag = False
if partner_feature_enabled :
raw_partner_enabled = ( partner_enabled or " " ) . strip ( ) . lower ( )
partner_flag = raw_partner_enabled in { " 1 " , " true " , " on " , " yes " }
auto_opt_out_flag = ( partner_auto_opt_out or " " ) . strip ( ) . lower ( ) in { " 1 " , " true " , " on " , " yes " }
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
# Validate primary tag selection if tags are available
available_tags = orch . tags_for_commander ( commander )
if available_tags and not ( primary_tag and primary_tag . strip ( ) ) :
2025-09-03 18:00:06 -07:00
# Compute GC flag to hide disallowed brackets on error
is_gc = False
try :
is_gc = bool ( commander in getattr ( bc , ' GAME_CHANGERS ' , [ ] ) )
except Exception :
is_gc = False
try :
sel_br = int ( bracket ) if bracket is not None else None
except Exception :
sel_br = None
if is_gc and ( sel_br is None or sel_br < 3 ) :
sel_br = 3
2025-10-06 09:17:59 -07:00
context = {
" request " : request ,
" commander " : { " name " : commander } ,
" tags " : available_tags ,
" recommended " : orch . recommended_tags_for_commander ( commander ) ,
" recommended_reasons " : orch . recommended_tag_reasons_for_commander ( commander ) ,
" brackets " : orch . bracket_options ( ) ,
" error " : " Please choose a primary theme. " ,
" primary_tag " : primary_tag or " " ,
" secondary_tag " : secondary_tag or " " ,
" tertiary_tag " : tertiary_tag or " " ,
" selected_bracket " : sel_br ,
" tag_mode " : ( tag_mode or " AND " ) ,
" gc_commander " : is_gc ,
}
context . update (
_partner_ui_context (
commander ,
partner_enabled = partner_flag ,
secondary_selection = secondary_commander if partner_flag else None ,
background_selection = background if partner_flag else None ,
combined_preview = None ,
warnings = [ ] ,
partner_error = None ,
auto_note = None ,
auto_assigned = None ,
auto_prefill_allowed = not auto_opt_out_flag ,
)
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
)
2025-10-06 09:17:59 -07:00
partner_tags = context . pop ( " partner_theme_tags " , None )
if partner_tags :
context [ " tags " ] = partner_tags
resp = templates . TemplateResponse ( " build/_step2.html " , context )
2025-08-26 20:00:07 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
2025-09-03 18:00:06 -07:00
# Enforce bracket restrictions for Game Changer commanders (silently coerce to 3 if needed)
try :
is_gc = bool ( commander in getattr ( bc , ' GAME_CHANGERS ' , [ ] ) )
except Exception :
is_gc = False
if is_gc :
try :
if int ( bracket ) < 3 :
bracket = 3 # coerce silently
except Exception :
bracket = 3
2025-10-06 09:17:59 -07:00
(
partner_error ,
combined_payload ,
partner_warnings ,
partner_auto_note ,
resolved_secondary ,
resolved_background ,
partner_mode ,
partner_auto_assigned_flag ,
) = _resolve_partner_selection (
commander ,
feature_enabled = partner_feature_enabled ,
partner_enabled = partner_flag ,
secondary_candidate = secondary_commander ,
background_candidate = background ,
auto_opt_out = auto_opt_out_flag ,
selection_source = partner_selection_source ,
)
if partner_error :
try :
sel_br = int ( bracket )
except Exception :
sel_br = None
context : dict [ str , Any ] = {
" request " : request ,
" commander " : { " name " : commander } ,
" tags " : available_tags ,
" recommended " : orch . recommended_tags_for_commander ( commander ) ,
" recommended_reasons " : orch . recommended_tag_reasons_for_commander ( commander ) ,
" brackets " : orch . bracket_options ( ) ,
" primary_tag " : primary_tag or " " ,
" secondary_tag " : secondary_tag or " " ,
" tertiary_tag " : tertiary_tag or " " ,
" selected_bracket " : sel_br ,
" tag_mode " : ( tag_mode or " AND " ) ,
" gc_commander " : is_gc ,
" error " : None ,
}
context . update (
_partner_ui_context (
commander ,
partner_enabled = partner_flag ,
secondary_selection = resolved_secondary or secondary_commander ,
background_selection = resolved_background or background ,
combined_preview = combined_payload ,
warnings = partner_warnings ,
partner_error = partner_error ,
auto_note = partner_auto_note ,
auto_assigned = partner_auto_assigned_flag ,
auto_prefill_allowed = not auto_opt_out_flag ,
)
)
partner_tags = context . pop ( " partner_theme_tags " , None )
if partner_tags :
context [ " tags " ] = partner_tags
resp = templates . TemplateResponse ( " build/_step2.html " , context )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
# Save selection to session (basic MVP; real build will use this later)
sess [ " commander " ] = commander
sess [ " tags " ] = [ t for t in [ primary_tag , secondary_tag , tertiary_tag ] if t ]
2025-08-26 11:34:42 -07:00
sess [ " tag_mode " ] = ( tag_mode or " AND " ) . upper ( )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
sess [ " bracket " ] = int ( bracket )
2025-10-06 09:17:59 -07:00
if partner_flag and combined_payload :
sess [ " partner_enabled " ] = True
if resolved_secondary :
sess [ " secondary_commander " ] = resolved_secondary
else :
sess . pop ( " secondary_commander " , None )
if resolved_background :
sess [ " background " ] = resolved_background
else :
sess . pop ( " background " , None )
if partner_mode :
sess [ " partner_mode " ] = partner_mode
else :
sess . pop ( " partner_mode " , None )
sess [ " combined_commander " ] = combined_payload
sess [ " partner_warnings " ] = partner_warnings
if partner_auto_note :
sess [ " partner_auto_note " ] = partner_auto_note
else :
sess . pop ( " partner_auto_note " , None )
sess [ " partner_auto_assigned " ] = bool ( partner_auto_assigned_flag )
sess [ " partner_auto_opt_out " ] = bool ( auto_opt_out_flag )
else :
sess [ " partner_enabled " ] = False
for key in [
" secondary_commander " ,
" background " ,
" partner_mode " ,
" partner_warnings " ,
" combined_commander " ,
" partner_auto_note " ,
] :
try :
sess . pop ( key )
except KeyError :
pass
for key in [ " partner_auto_assigned " , " partner_auto_opt_out " ] :
try :
sess . pop ( key )
except KeyError :
pass
2025-08-29 09:19:03 -07:00
# Clear multi-copy seen/selection to re-evaluate on Step 3
try :
if " mc_seen_keys " in sess :
del sess [ " mc_seen_keys " ]
if " multi_copy " in sess :
del sess [ " multi_copy " ]
if " mc_applied_key " in sess :
del sess [ " mc_applied_key " ]
except Exception :
pass
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
# Proceed to Step 3 placeholder for now
2025-08-26 20:00:07 -07:00
sess [ " last_step " ] = 3
resp = templates . TemplateResponse (
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
" build/_step3.html " ,
{
" request " : request ,
" commander " : commander ,
" tags " : sess [ " tags " ] ,
" bracket " : sess [ " bracket " ] ,
" defaults " : orch . ideal_defaults ( ) ,
" labels " : orch . ideal_labels ( ) ,
" values " : orch . ideal_defaults ( ) ,
} ,
)
2025-08-26 20:00:07 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
@router.post ( " /step3 " , response_class = HTMLResponse )
async def build_step3_submit (
request : Request ,
ramp : int = Form ( . . . ) ,
lands : int = Form ( . . . ) ,
basic_lands : int = Form ( . . . ) ,
creatures : int = Form ( . . . ) ,
removal : int = Form ( . . . ) ,
wipes : int = Form ( . . . ) ,
card_advantage : int = Form ( . . . ) ,
protection : int = Form ( . . . ) ,
) - > HTMLResponse :
labels = orch . ideal_labels ( )
submitted = {
" ramp " : ramp ,
" lands " : lands ,
" basic_lands " : basic_lands ,
" creatures " : creatures ,
" removal " : removal ,
" wipes " : wipes ,
" card_advantage " : card_advantage ,
" protection " : protection ,
}
errors : list [ str ] = [ ]
for k , v in submitted . items ( ) :
try :
iv = int ( v )
except Exception :
errors . append ( f " { labels . get ( k , k ) } must be a number. " )
continue
if iv < 0 :
errors . append ( f " { labels . get ( k , k ) } cannot be negative. " )
submitted [ k ] = iv
# Cross-field validation: basic lands should not exceed total lands
if isinstance ( submitted . get ( " basic_lands " ) , int ) and isinstance ( submitted . get ( " lands " ) , int ) :
if submitted [ " basic_lands " ] > submitted [ " lands " ] :
errors . append ( " Basic Lands cannot exceed Total Lands. " )
if errors :
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-08-26 20:00:07 -07:00
sess [ " last_step " ] = 3
resp = templates . TemplateResponse (
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
" build/_step3.html " ,
{
" request " : request ,
" defaults " : orch . ideal_defaults ( ) ,
" labels " : labels ,
" values " : submitted ,
" error " : " " . join ( errors ) ,
" commander " : sess . get ( " commander " ) ,
" tags " : sess . get ( " tags " , [ ] ) ,
" bracket " : sess . get ( " bracket " ) ,
} ,
)
2025-08-26 20:00:07 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
# Save to session
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
sess [ " ideals " ] = submitted
2025-08-29 09:19:03 -07:00
# Any change to ideals should clear the applied marker, we may want to re-stage
try :
if " mc_applied_key " in sess :
del sess [ " mc_applied_key " ]
except Exception :
pass
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
# Proceed to review (Step 4)
2025-08-26 20:00:07 -07:00
sess [ " last_step " ] = 4
resp = templates . TemplateResponse (
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
" build/_step4.html " ,
{
" request " : request ,
" labels " : labels ,
" values " : submitted ,
" commander " : sess . get ( " commander " ) ,
2025-10-02 15:31:05 -07:00
" owned_only " : bool ( sess . get ( " use_owned_only " ) ) ,
" prefer_owned " : bool ( sess . get ( " prefer_owned " ) ) ,
" swap_mdfc_basics " : bool ( sess . get ( " swap_mdfc_basics " ) ) ,
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
} ,
)
2025-08-26 20:00:07 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
@router.get ( " /step3 " , response_class = HTMLResponse )
async def build_step3_get ( request : Request ) - > HTMLResponse :
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-08-26 20:00:07 -07:00
sess [ " last_step " ] = 3
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
defaults = orch . ideal_defaults ( )
values = sess . get ( " ideals " ) or defaults
2025-10-28 08:21:52 -07:00
# Check if any skip flags are enabled to show skeleton automation page
skip_flags = {
" skip_lands " : " land selection " ,
" skip_to_misc " : " land selection " ,
" skip_basics " : " basic lands " ,
" skip_staples " : " staple lands " ,
" skip_kindred " : " kindred lands " ,
" skip_fetches " : " fetch lands " ,
" skip_duals " : " dual lands " ,
" skip_triomes " : " triome lands " ,
" skip_all_creatures " : " creature selection " ,
" skip_creature_primary " : " primary creatures " ,
" skip_creature_secondary " : " secondary creatures " ,
" skip_creature_fill " : " creature fills " ,
" skip_all_spells " : " spell selection " ,
" skip_ramp " : " ramp spells " ,
" skip_removal " : " removal spells " ,
" skip_wipes " : " board wipes " ,
" skip_card_advantage " : " card advantage spells " ,
" skip_protection " : " protection spells " ,
" skip_spell_fill " : " spell fills " ,
}
active_skips = [ desc for key , desc in skip_flags . items ( ) if sess . get ( key , False ) ]
if active_skips :
# Show skeleton automation page with auto-submit
automation_parts = [ ]
if any ( " land " in s for s in active_skips ) :
automation_parts . append ( " lands " )
if any ( " creature " in s for s in active_skips ) :
automation_parts . append ( " creatures " )
if any ( " spell " in s for s in active_skips ) :
automation_parts . append ( " spells " )
automation_message = f " Applying default values for { ' , ' . join ( automation_parts ) } ... "
resp = templates . TemplateResponse (
" build/_step3_skeleton.html " ,
{
" request " : request ,
" defaults " : defaults ,
" commander " : sess . get ( " commander " ) ,
" automation_message " : automation_message ,
} ,
)
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
# No skips enabled, show normal form
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
resp = templates . TemplateResponse (
" build/_step3.html " ,
{
" request " : request ,
" defaults " : defaults ,
" labels " : orch . ideal_labels ( ) ,
" values " : values ,
" commander " : sess . get ( " commander " ) ,
" tags " : sess . get ( " tags " , [ ] ) ,
" bracket " : sess . get ( " bracket " ) ,
} ,
)
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
@router.get ( " /step4 " , response_class = HTMLResponse )
async def build_step4_get ( request : Request ) - > HTMLResponse :
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-08-26 20:00:07 -07:00
sess [ " last_step " ] = 4
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
labels = orch . ideal_labels ( )
values = sess . get ( " ideals " ) or orch . ideal_defaults ( )
commander = sess . get ( " commander " )
return templates . TemplateResponse (
" build/_step4.html " ,
{
" request " : request ,
" labels " : labels ,
" values " : values ,
" commander " : commander ,
2025-08-26 16:25:34 -07:00
" owned_only " : bool ( sess . get ( " use_owned_only " ) ) ,
" prefer_owned " : bool ( sess . get ( " prefer_owned " ) ) ,
2025-10-02 15:31:05 -07:00
" swap_mdfc_basics " : bool ( sess . get ( " swap_mdfc_basics " ) ) ,
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
} ,
)
2025-09-01 16:55:24 -07:00
# --- Combos & Synergies panel (M3) ---
def _get_current_deck_names ( sess : dict ) - > list [ str ] :
try :
ctx = sess . get ( " build_ctx " ) or { }
b = ctx . get ( " builder " )
lib = getattr ( b , " card_library " , { } ) if b is not None else { }
names = [ str ( n ) for n in lib . keys ( ) ]
return sorted ( dict . fromkeys ( names ) )
except Exception :
return [ ]
@router.get ( " /combos " , response_class = HTMLResponse )
async def build_combos_panel ( request : Request ) - > HTMLResponse :
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
names = _get_current_deck_names ( sess )
if not names :
# No active build; render nothing to avoid UI clutter
return HTMLResponse ( " " )
# Preferences (persisted in session)
policy = ( sess . get ( " combos_policy " ) or " neutral " ) . lower ( )
if policy not in { " avoid " , " neutral " , " prefer " } :
policy = " neutral "
try :
target = int ( sess . get ( " combos_target " ) or 0 )
except Exception :
target = 0
if target < 0 :
target = 0
# Load lists and run detection
2025-09-02 11:39:14 -07:00
_det = _detect_all ( names )
combos = _det . get ( " combos " , [ ] )
synergies = _det . get ( " synergies " , [ ] )
combos_model = _det . get ( " combos_model " )
synergies_model = _det . get ( " synergies_model " )
2025-09-01 16:55:24 -07:00
# Suggestions
suggestions : list [ dict ] = [ ]
present = { s . strip ( ) . lower ( ) for s in names }
suggested_names : set [ str ] = set ( )
if combos_model is not None :
# Prefer policy: suggest adding a missing partner to hit target count
if policy == " prefer " :
try :
for p in combos_model . pairs :
a = str ( p . a ) . strip ( )
b = str ( p . b ) . strip ( )
a_in = a . lower ( ) in present
b_in = b . lower ( ) in present
if a_in ^ b_in : # exactly one present
missing = b if a_in else a
have = a if a_in else b
item = {
" kind " : " add " ,
" have " : have ,
" name " : missing ,
" cheap_early " : bool ( getattr ( p , " cheap_early " , False ) ) ,
" setup_dependent " : bool ( getattr ( p , " setup_dependent " , False ) ) ,
}
key = str ( missing ) . strip ( ) . lower ( )
if key not in present and key not in suggested_names :
suggestions . append ( item )
suggested_names . add ( key )
# Rank: cheap/early first, then setup-dependent, then name
suggestions . sort ( key = lambda s : ( 0 if s . get ( " cheap_early " ) else 1 , 0 if s . get ( " setup_dependent " ) else 1 , str ( s . get ( " name " ) ) . lower ( ) ) )
# If we still have room below target, add synergy-based suggestions
rem = ( max ( 0 , int ( target ) ) if target > 0 else 8 ) - len ( suggestions )
if rem > 0 and synergies_model is not None :
# lightweight tag weights to bias common engines
weights = {
" treasure " : 3.0 , " tokens " : 2.8 , " landfall " : 2.6 , " card draw " : 2.5 , " ramp " : 2.3 ,
" engine " : 2.2 , " value " : 2.1 , " artifacts " : 2.0 , " enchantress " : 2.0 , " spellslinger " : 1.9 ,
feat(editorial): Phase D synergy commander enrichment, augmentation, lint & docs\n\nAdds Phase D editorial tooling: synergy-based commander selection with 3/2/1 pattern, duplicate filtering, annotated synergy_commanders, promotion to minimum examples, and augmentation heuristics (e.g. Counters Matter/Proliferate injection). Includes new scripts (generate_theme_editorial_suggestions, lint, validate, catalog build/apply), updates orchestrator & web routes, expands CI workflow, and documents usage & non-determinism policies. Updates lint rules, type definitions, and docker configs.
2025-09-18 10:59:20 -07:00
" counters " : 1.8 , " equipment matters " : 1.7 , " tribal " : 1.6 , " lifegain " : 1.5 , " mill " : 1.4 ,
2025-09-01 16:55:24 -07:00
" damage " : 1.3 , " stax " : 1.2
}
syn_sugs : list [ dict ] = [ ]
for p in synergies_model . pairs :
a = str ( p . a ) . strip ( )
b = str ( p . b ) . strip ( )
a_in = a . lower ( ) in present
b_in = b . lower ( ) in present
if a_in ^ b_in :
missing = b if a_in else a
have = a if a_in else b
mkey = missing . strip ( ) . lower ( )
if mkey in present or mkey in suggested_names :
continue
tags = list ( getattr ( p , " tags " , [ ] ) or [ ] )
score = 1.0 + sum ( weights . get ( str ( t ) . lower ( ) , 1.0 ) for t in tags ) / max ( 1 , len ( tags ) or 1 )
syn_sugs . append ( {
" kind " : " add " ,
" have " : have ,
" name " : missing ,
" cheap_early " : False ,
" setup_dependent " : False ,
" tags " : tags ,
" _score " : score ,
} )
suggested_names . add ( mkey )
# rank by score desc then name
syn_sugs . sort ( key = lambda s : ( - float ( s . get ( " _score " , 0.0 ) ) , str ( s . get ( " name " ) ) . lower ( ) ) )
if rem > 0 :
suggestions . extend ( syn_sugs [ : rem ] )
# Finally trim to target or default cap
cap = ( int ( target ) if target > 0 else 8 )
suggestions = suggestions [ : cap ]
except Exception :
suggestions = [ ]
elif policy == " avoid " :
# Avoid policy: suggest cutting one piece from detected combos
try :
for c in combos :
# pick the second card as default cut to vary suggestions
suggestions . append ( {
" kind " : " cut " ,
" name " : c . b ,
" partner " : c . a ,
" cheap_early " : bool ( getattr ( c , " cheap_early " , False ) ) ,
" setup_dependent " : bool ( getattr ( c , " setup_dependent " , False ) ) ,
} )
# Rank: cheap/early first
suggestions . sort ( key = lambda s : ( 0 if s . get ( " cheap_early " ) else 1 , 0 if s . get ( " setup_dependent " ) else 1 , str ( s . get ( " name " ) ) . lower ( ) ) )
if target > 0 :
suggestions = suggestions [ : target ]
else :
suggestions = suggestions [ : 8 ]
except Exception :
suggestions = [ ]
ctx = {
" request " : request ,
" policy " : policy ,
" target " : target ,
" combos " : combos ,
" synergies " : synergies ,
2025-09-02 11:39:14 -07:00
" versions " : _det . get ( " versions " , { } ) ,
2025-09-01 16:55:24 -07:00
" suggestions " : suggestions ,
}
return templates . TemplateResponse ( " build/_combos_panel.html " , ctx )
@router.post ( " /combos/prefs " , response_class = HTMLResponse )
async def build_combos_save_prefs ( request : Request , policy : str = Form ( " neutral " ) , target : int = Form ( 0 ) ) - > HTMLResponse :
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
pol = ( policy or " neutral " ) . strip ( ) . lower ( )
if pol not in { " avoid " , " neutral " , " prefer " } :
pol = " neutral "
try :
tgt = int ( target )
except Exception :
tgt = 0
if tgt < 0 :
tgt = 0
sess [ " combos_policy " ] = pol
sess [ " combos_target " ] = tgt
# Re-render the panel
return await build_combos_panel ( request )
2025-08-26 16:25:34 -07:00
@router.post ( " /toggle-owned-review " , response_class = HTMLResponse )
async def build_toggle_owned_review (
request : Request ,
use_owned_only : str | None = Form ( None ) ,
prefer_owned : str | None = Form ( None ) ,
2025-10-02 15:31:05 -07:00
swap_mdfc_basics : str | None = Form ( None ) ,
2025-08-26 16:25:34 -07:00
) - > HTMLResponse :
""" Toggle ' use owned only ' and/or ' prefer owned ' flags from the Review step and re-render Step 4. """
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-08-26 20:00:07 -07:00
sess [ " last_step " ] = 4
2025-08-26 16:25:34 -07:00
only_val = True if ( use_owned_only and str ( use_owned_only ) . strip ( ) in ( " 1 " , " true " , " on " , " yes " ) ) else False
pref_val = True if ( prefer_owned and str ( prefer_owned ) . strip ( ) in ( " 1 " , " true " , " on " , " yes " ) ) else False
2025-10-02 15:31:05 -07:00
swap_val = True if ( swap_mdfc_basics and str ( swap_mdfc_basics ) . strip ( ) in ( " 1 " , " true " , " on " , " yes " ) ) else False
2025-08-26 16:25:34 -07:00
sess [ " use_owned_only " ] = only_val
sess [ " prefer_owned " ] = pref_val
2025-10-02 15:31:05 -07:00
sess [ " swap_mdfc_basics " ] = swap_val
2025-08-26 16:25:34 -07:00
# Do not touch build_ctx here; user hasn't started the build yet from review
labels = orch . ideal_labels ( )
values = sess . get ( " ideals " ) or orch . ideal_defaults ( )
commander = sess . get ( " commander " )
resp = templates . TemplateResponse (
" build/_step4.html " ,
{
" request " : request ,
" labels " : labels ,
" values " : values ,
" commander " : commander ,
" owned_only " : bool ( sess . get ( " use_owned_only " ) ) ,
" prefer_owned " : bool ( sess . get ( " prefer_owned " ) ) ,
2025-10-02 15:31:05 -07:00
" swap_mdfc_basics " : bool ( sess . get ( " swap_mdfc_basics " ) ) ,
2025-08-26 16:25:34 -07:00
} ,
)
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
@router.get ( " /step5 " , response_class = HTMLResponse )
async def build_step5_get ( request : Request ) - > HTMLResponse :
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-08-26 20:00:07 -07:00
sess [ " last_step " ] = 5
2025-08-28 14:57:22 -07:00
# Default replace-mode to ON unless explicitly toggled off
if " replace_mode " not in sess :
sess [ " replace_mode " ] = True
2025-09-02 11:39:14 -07:00
base = step5_empty_ctx ( request , sess )
resp = templates . TemplateResponse ( " build/_step5.html " , base )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : base . get ( " summary_token " , 0 ) } } )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
return resp
@router.post ( " /step5/continue " , response_class = HTMLResponse )
async def build_step5_continue ( request : Request ) - > HTMLResponse :
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-08-28 14:57:22 -07:00
if " replace_mode " not in sess :
sess [ " replace_mode " ] = True
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
# Validate commander; redirect to step1 if missing
if not sess . get ( " commander " ) :
resp = templates . TemplateResponse ( " build/_step1.html " , { " request " : request , " candidates " : [ ] , " error " : " Please select a commander first. " } )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
# Ensure build context exists; if not, start it first
if not sess . get ( " build_ctx " ) :
2025-09-02 11:39:14 -07:00
sess [ " build_ctx " ] = start_ctx_from_session ( sess )
2025-08-29 09:19:03 -07:00
else :
# If context exists already, rebuild ONLY when the multi-copy selection changed or hasn't been applied yet
try :
mc = sess . get ( " multi_copy " ) or None
selkey = None
if mc :
selkey = f " { mc . get ( ' id ' , ' ' ) } | { int ( mc . get ( ' count ' , 0 ) ) } | { 1 if mc . get ( ' thrumming ' ) else 0 } "
applied = sess . get ( " mc_applied_key " ) if mc else None
if mc and ( not applied or applied != selkey ) :
_rebuild_ctx_with_multicopy ( sess )
# If we still have no stages (e.g., minimal test context), inject a minimal multi-copy stage inline
try :
ctx = sess . get ( " build_ctx " ) or { }
stages = ctx . get ( " stages " ) if isinstance ( ctx , dict ) else None
if ( not stages or len ( stages ) == 0 ) and mc :
b = ctx . get ( " builder " ) if isinstance ( ctx , dict ) else None
if b is not None :
try :
setattr ( b , " _web_multi_copy " , mc )
except Exception :
pass
try :
if not isinstance ( getattr ( b , " card_library " , None ) , dict ) :
b . card_library = { }
except Exception :
pass
try :
if not isinstance ( getattr ( b , " ideal_counts " , None ) , dict ) :
b . ideal_counts = { }
except Exception :
pass
ctx [ " stages " ] = [ { " key " : " multicopy " , " label " : " Multi-Copy Package " , " runner_name " : " __add_multi_copy__ " } ]
ctx [ " idx " ] = 0
ctx [ " last_visible_idx " ] = 0
except Exception :
pass
except Exception :
pass
2025-08-26 20:00:07 -07:00
# Read show_skipped from either query or form safely
show_skipped = True if ( request . query_params . get ( ' show_skipped ' ) == ' 1 ' ) else False
2025-08-26 16:25:34 -07:00
try :
form = await request . form ( )
2025-08-26 20:00:07 -07:00
if form and form . get ( ' show_skipped ' ) == ' 1 ' :
show_skipped = True
2025-08-26 16:25:34 -07:00
except Exception :
pass
2025-09-02 11:39:14 -07:00
try :
res = orch . run_stage ( sess [ " build_ctx " ] , rerun = False , show_skipped = show_skipped )
status = " Build complete " if res . get ( " done " ) else " Stage complete "
2025-10-14 16:09:58 -07:00
# Clear commander from session after build completes
if res . get ( " done " ) :
sess . pop ( " commander " , None )
sess . pop ( " commander_name " , None )
2025-09-02 11:39:14 -07:00
except Exception as e :
sess [ " last_step " ] = 5
err_ctx = step5_error_ctx ( request , sess , f " Failed to continue: { e } " )
resp = templates . TemplateResponse ( " build/_step5.html " , err_ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : err_ctx . get ( " summary_token " , 0 ) } } )
2025-09-02 11:39:14 -07:00
return resp
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
stage_label = res . get ( " label " )
2025-08-29 09:19:03 -07:00
# If we just applied Multi-Copy, stamp the applied key so we don't rebuild again
try :
if stage_label == " Multi-Copy Package " and sess . get ( " multi_copy " ) :
mc = sess . get ( " multi_copy " )
sess [ " mc_applied_key " ] = f " { mc . get ( ' id ' , ' ' ) } | { int ( mc . get ( ' count ' , 0 ) ) } | { 1 if mc . get ( ' thrumming ' ) else 0 } "
except Exception :
pass
2025-09-03 18:00:06 -07:00
# Note: no redirect; the inline compliance panel will render inside Step 5
2025-08-26 20:00:07 -07:00
sess [ " last_step " ] = 5
2025-09-02 11:39:14 -07:00
ctx2 = step5_ctx_from_result ( request , sess , res , status_text = status , show_skipped = show_skipped )
resp = templates . TemplateResponse ( " build/_step5.html " , ctx2 )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : ctx2 . get ( " summary_token " , 0 ) } } )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
return resp
@router.post ( " /step5/rerun " , response_class = HTMLResponse )
async def build_step5_rerun ( request : Request ) - > HTMLResponse :
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-08-28 14:57:22 -07:00
if " replace_mode " not in sess :
sess [ " replace_mode " ] = True
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
if not sess . get ( " commander " ) :
resp = templates . TemplateResponse ( " build/_step1.html " , { " request " : request , " candidates " : [ ] , " error " : " Please select a commander first. " } )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
# Rerun requires an existing context; if missing, create it and run first stage as rerun
if not sess . get ( " build_ctx " ) :
2025-09-02 11:39:14 -07:00
sess [ " build_ctx " ] = start_ctx_from_session ( sess )
2025-08-28 14:57:22 -07:00
else :
# Ensure latest locks are reflected in the existing context
try :
sess [ " build_ctx " ] [ " locks " ] = { str ( x ) . strip ( ) . lower ( ) for x in ( sess . get ( " locks " , [ ] ) or [ ] ) }
except Exception :
pass
2025-08-26 16:25:34 -07:00
show_skipped = False
try :
form = await request . form ( )
show_skipped = True if ( form . get ( ' show_skipped ' ) == ' 1 ' ) else False
except Exception :
pass
2025-08-28 14:57:22 -07:00
# If replace-mode is OFF, keep the stage visible even if no new cards were added
if not bool ( sess . get ( " replace_mode " , True ) ) :
show_skipped = True
2025-09-02 11:39:14 -07:00
try :
res = orch . run_stage ( sess [ " build_ctx " ] , rerun = True , show_skipped = show_skipped , replace = bool ( sess . get ( " replace_mode " , True ) ) )
status = " Stage rerun complete " if not res . get ( " done " ) else " Build complete "
except Exception as e :
sess [ " last_step " ] = 5
err_ctx = step5_error_ctx ( request , sess , f " Failed to rerun stage: { e } " )
resp = templates . TemplateResponse ( " build/_step5.html " , err_ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : err_ctx . get ( " summary_token " , 0 ) } } )
2025-09-02 11:39:14 -07:00
return resp
2025-08-26 20:00:07 -07:00
sess [ " last_step " ] = 5
2025-08-28 14:57:22 -07:00
# Build locked cards list with ownership and in-deck presence
locked_cards = [ ]
try :
ctx = sess . get ( " build_ctx " ) or { }
b = ctx . get ( " builder " ) if isinstance ( ctx , dict ) else None
2025-09-02 11:39:14 -07:00
present : set [ str ] = builder_present_names ( b ) if b is not None else set ( )
2025-08-28 14:57:22 -07:00
# Display-map via combined df when available
2025-09-02 11:39:14 -07:00
lock_lower = { str ( x ) . strip ( ) . lower ( ) for x in ( sess . get ( " locks " , [ ] ) or [ ] ) }
display_map : dict [ str , str ] = builder_display_map ( b , lock_lower ) if b is not None else { }
owned_lower = owned_set_helper ( )
2025-08-28 14:57:22 -07:00
for nm in ( sess . get ( " locks " , [ ] ) or [ ] ) :
key = str ( nm ) . strip ( ) . lower ( )
disp = display_map . get ( key , nm )
locked_cards . append ( {
" name " : disp ,
" owned " : key in owned_lower ,
" in_deck " : key in present ,
} )
except Exception :
locked_cards = [ ]
2025-09-02 11:39:14 -07:00
ctx3 = step5_ctx_from_result ( request , sess , res , status_text = status , show_skipped = show_skipped )
ctx3 [ " locked_cards " ] = locked_cards
resp = templates . TemplateResponse ( " build/_step5.html " , ctx3 )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : ctx3 . get ( " summary_token " , 0 ) } } )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
return resp
@router.post ( " /step5/start " , response_class = HTMLResponse )
async def build_step5_start ( request : Request ) - > HTMLResponse :
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-08-28 14:57:22 -07:00
if " replace_mode " not in sess :
sess [ " replace_mode " ] = True
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
# Validate commander exists before starting
commander = sess . get ( " commander " )
if not commander :
resp = templates . TemplateResponse (
" build/_step1.html " ,
{ " request " : request , " candidates " : [ ] , " error " : " Please select a commander first. " } ,
)
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
try :
# Initialize step-by-step build context and run first stage
2025-09-02 11:39:14 -07:00
sess [ " build_ctx " ] = start_ctx_from_session ( sess )
2025-08-26 16:25:34 -07:00
show_skipped = False
try :
form = await request . form ( )
show_skipped = True if ( form . get ( ' show_skipped ' ) == ' 1 ' ) else False
except Exception :
pass
res = orch . run_stage ( sess [ " build_ctx " ] , rerun = False , show_skipped = show_skipped )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
status = " Stage complete " if not res . get ( " done " ) else " Build complete "
2025-08-29 09:19:03 -07:00
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
try :
2025-09-02 11:39:14 -07:00
if res . get ( " label " ) == " Multi-Copy Package " and sess . get ( " multi_copy " ) :
2025-08-29 09:19:03 -07:00
mc = sess . get ( " multi_copy " )
sess [ " mc_applied_key " ] = f " { mc . get ( ' id ' , ' ' ) } | { int ( mc . get ( ' count ' , 0 ) ) } | { 1 if mc . get ( ' thrumming ' ) else 0 } "
except Exception :
pass
2025-09-03 18:00:06 -07:00
# Note: no redirect; the inline compliance panel will render inside Step 5
2025-08-26 20:00:07 -07:00
sess [ " last_step " ] = 5
2025-09-02 11:39:14 -07:00
ctx = step5_ctx_from_result ( request , sess , res , status_text = status , show_skipped = show_skipped )
resp = templates . TemplateResponse ( " build/_step5.html " , ctx )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : ctx . get ( " summary_token " , 0 ) } } )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
return resp
except Exception as e :
2025-09-02 11:39:14 -07:00
# Surface a friendly error on the step 5 screen with normalized context
err_ctx = step5_error_ctx (
request ,
sess ,
f " Failed to start build: { e } " ,
include_name = False ,
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
)
2025-09-02 11:39:14 -07:00
# Ensure commander stays visible if set
err_ctx [ " commander " ] = commander
resp = templates . TemplateResponse ( " build/_step5.html " , err_ctx )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : err_ctx . get ( " summary_token " , 0 ) } } )
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
return resp
@router.get ( " /step5/start " , response_class = HTMLResponse )
async def build_step5_start_get ( request : Request ) - > HTMLResponse :
# Allow GET as a fallback to start the build (delegates to POST handler)
return await build_step5_start ( request )
@router.get ( " /banner " , response_class = HTMLResponse )
async def build_banner ( request : Request , step : str = " " , i : int | None = None , n : int | None = None ) - > HTMLResponse :
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
commander = sess . get ( " commander " )
tags = sess . get ( " tags " , [ ] )
# Render only the inner text for the subtitle
return templates . TemplateResponse (
" build/_banner_subtitle.html " ,
2025-08-28 14:57:22 -07:00
{ " request " : request , " commander " : commander , " tags " : tags , " name " : sess . get ( " custom_export_base " ) } ,
)
@router.post ( " /step5/toggle-replace " )
async def build_step5_toggle_replace ( request : Request , replace : str = Form ( " 0 " ) ) :
""" Toggle replace-mode for reruns and return an updated button HTML. """
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
enabled = True if str ( replace ) . strip ( ) in ( " 1 " , " true " , " on " , " yes " ) else False
sess [ " replace_mode " ] = enabled
# Return the checkbox control snippet (same as template)
checked = ' checked ' if enabled else ' '
html = (
' <div class= " replace-toggle " role= " group " aria-label= " Replace toggle " > '
' <form hx-post= " /build/step5/toggle-replace " hx-target= " closest .replace-toggle " hx-swap= " outerHTML " onsubmit= " return false; " style= " display:inline; " > '
f ' <input type= " hidden " name= " replace " value= " { " 1 " if enabled else " 0 " } " /> '
' <label class= " muted " style= " display:flex; align-items:center; gap:.35rem; " > '
f ' <input type= " checkbox " name= " replace_chk " value= " 1 " { checked } '
' onchange= " try { const f=this.form; const h=f.querySelector( \' input[name=replace] \' ); if(h) { h.value=this.checked? \' 1 \' : \' 0 \' ; } f.requestSubmit(); }catch(_) { } " /> '
' Replace stage picks '
' </label> '
' </form> '
' </div> '
)
return HTMLResponse ( html )
@router.post ( " /step5/reset-stage " , response_class = HTMLResponse )
async def build_step5_reset_stage ( request : Request ) - > HTMLResponse :
""" Reset current visible stage to the pre-stage snapshot (if available) without running it. """
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
ctx = sess . get ( " build_ctx " )
if not ctx or not ctx . get ( " snapshot " ) :
return await build_step5_get ( request )
try :
orch . _restore_builder ( ctx [ " builder " ] , ctx [ " snapshot " ] ) # type: ignore[attr-defined]
except Exception :
return await build_step5_get ( request )
# Re-render step 5 with cleared added list
2025-09-02 11:39:14 -07:00
base = step5_empty_ctx ( request , sess , extras = {
" status " : " Stage reset " ,
" i " : ctx . get ( " idx " ) ,
" n " : len ( ctx . get ( " stages " , [ ] ) ) ,
} )
resp = templates . TemplateResponse ( " build/_step5.html " , base )
2025-08-28 14:57:22 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : base . get ( " summary_token " , 0 ) } } )
2025-08-28 14:57:22 -07:00
return resp
2025-10-08 11:38:30 -07:00
@router.get ( " /step5/summary " , response_class = HTMLResponse )
async def build_step5_summary ( request : Request , token : int = Query ( 0 ) ) - > HTMLResponse :
sid = request . cookies . get ( " sid " ) or request . headers . get ( " X-Session-ID " )
if not sid :
sid = new_sid ( )
sess = get_session ( sid )
try :
session_token = int ( sess . get ( " step5_summary_token " , 0 ) )
except Exception :
session_token = 0
try :
requested_token = int ( token )
except Exception :
requested_token = 0
ready = bool ( sess . get ( " step5_summary_ready " ) )
summary_data = sess . get ( " step5_summary " ) if ready else None
if summary_data is None and ready :
summary_data = _current_builder_summary ( sess )
if summary_data is not None :
try :
sess [ " step5_summary " ] = summary_data
except Exception :
pass
synergies : list [ str ] = [ ]
try :
raw_synergies = sess . get ( " step5_synergies " )
if isinstance ( raw_synergies , ( list , tuple , set ) ) :
synergies = [ str ( item ) for item in raw_synergies if str ( item ) . strip ( ) ]
except Exception :
synergies = [ ]
active_token = session_token if session_token > = requested_token else requested_token
if not ready or summary_data is None :
message = " Deck summary will appear after the build completes. " if not ready else " Deck summary is not available yet. Try rerunning the current stage. "
placeholder = _step5_summary_placeholder_html ( active_token , message = message )
response = HTMLResponse ( placeholder )
response . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return response
ctx = step5_base_ctx ( request , sess )
ctx [ " summary " ] = summary_data
ctx [ " synergies " ] = synergies
ctx [ " summary_ready " ] = True
ctx [ " summary_token " ] = active_token
2025-10-28 08:21:52 -07:00
# Add commander hover context for color identity and theme tags
hover_meta = commander_hover_context (
commander_name = ctx . get ( " commander " ) ,
deck_tags = sess . get ( " tags " ) ,
summary = summary_data ,
combined = ctx . get ( " combined_commander " ) ,
)
ctx . update ( hover_meta )
2025-10-08 11:38:30 -07:00
response = templates . TemplateResponse ( " partials/deck_summary.html " , ctx )
response . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return response
2025-10-14 16:09:58 -07:00
@router.get ( " /quick-progress " )
def quick_build_progress ( request : Request ) :
""" Poll endpoint for Quick Build progress. Returns either progress indicator or final Step 5. """
import logging
logger = logging . getLogger ( __name__ )
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
progress = sess . get ( " quick_build_progress " )
logger . info ( f " [Progress Poll] sid= { sid } , progress= { progress is not None } , running= { progress . get ( ' running ' ) if progress else None } " )
if not progress or not progress . get ( " running " ) :
2025-10-14 17:47:27 -07:00
# Build complete - return Step 5 content that replaces the entire wizard container
2025-10-14 16:09:58 -07:00
res = sess . get ( " last_result " )
if res and res . get ( " done " ) :
ctx = step5_ctx_from_result ( request , sess , res )
2025-10-14 17:47:27 -07:00
# Return Step 5 which will replace the whole wizard div
response = templates . TemplateResponse ( " build/_step5.html " , ctx )
2025-10-14 16:09:58 -07:00
response . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-14 17:47:27 -07:00
# Tell HTMX to target #wizard and swap outerHTML to replace the container
response . headers [ " HX-Retarget " ] = " #wizard "
response . headers [ " HX-Reswap " ] = " outerHTML "
2025-10-14 16:09:58 -07:00
return response
# Fallback if no result yet
return HTMLResponse ( ' Build complete. Please refresh. ' )
# Build still running - return progress content partial only (innerHTML swap)
current_stage = progress . get ( " current_stage " , " Processing... " )
ctx = {
" request " : request ,
" current_stage " : current_stage
}
response = templates . TemplateResponse ( " build/_quick_build_progress_content.html " , ctx )
response . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return response
2025-10-20 18:29:53 -07:00
@router.get ( " /batch-progress " )
def batch_build_progress ( request : Request , batch_id : str = Query ( . . . ) ) :
""" Poll endpoint for Batch Build progress. Returns either progress indicator or redirect to comparison. """
import logging
logger = logging . getLogger ( __name__ )
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
from . . services . build_cache import BuildCache
batch_status = BuildCache . get_batch_status ( sess , batch_id )
logger . info ( f " [Batch Progress Poll] batch_id= { batch_id } , status= { batch_status } " )
if not batch_status :
return HTMLResponse ( ' <div class= " error " >Batch not found. Please refresh.</div> ' )
if batch_status [ " status " ] == " completed " :
# All builds complete - redirect to comparison page
response = HTMLResponse ( f ' <script>window.location.href = " /compare/ { batch_id } " ;</script> ' )
response . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return response
# Get config to determine color count for time estimate
config = BuildCache . get_batch_config ( sess , batch_id )
commander_name = config . get ( " commander " , " " ) if config else " "
# Estimate time based on color count (from testing data)
time_estimate = " 1-3 minutes "
if commander_name and config :
# Try to get commander's color identity
try :
from . . services import orchestrator as orch
cmd_data = orch . load_commander ( commander_name )
if cmd_data and " colorIdentity " in cmd_data :
color_count = len ( cmd_data . get ( " colorIdentity " , [ ] ) )
if color_count < = 2 :
time_estimate = " 1-3 minutes "
elif color_count == 3 :
time_estimate = " 2-4 minutes "
else : # 4-5 colors
time_estimate = " 3-5 minutes "
except Exception :
pass # Default to 1-3 if we can't determine
# Build still running - return progress content partial only
ctx = {
" request " : request ,
" batch_id " : batch_id ,
" build_count " : batch_status [ " count " ] ,
" completed " : batch_status [ " completed " ] ,
" progress_pct " : batch_status [ " progress_pct " ] ,
" status " : f " Building deck { batch_status [ ' completed ' ] + 1 } of { batch_status [ ' count ' ] } ... " if batch_status [ ' completed ' ] < batch_status [ ' count ' ] else " Finalizing... " ,
" has_errors " : batch_status [ " has_errors " ] ,
" error_count " : batch_status [ " error_count " ] ,
" time_estimate " : time_estimate
}
response = templates . TemplateResponse ( " build/_batch_progress_content.html " , ctx )
response . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return response
2025-08-28 14:57:22 -07:00
# --- Phase 8: Lock/Replace/Compare/Permalink minimal API ---
@router.post ( " /lock " )
async def build_lock_toggle ( request : Request , name : str = Form ( . . . ) , locked : str = Form ( " 1 " ) , from_list : str | None = Form ( None ) ) :
""" Toggle lock for a card name in the current session; return an HTML button to swap in-place. """
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
locks = set ( sess . get ( " locks " , [ ] ) )
key = str ( name ) . strip ( ) . lower ( )
want_lock = True if str ( locked ) . strip ( ) in ( " 1 " , " true " , " on " , " yes " ) else False
if want_lock :
locks . add ( key )
else :
locks . discard ( key )
sess [ " locks " ] = list ( locks )
# If a build context exists, update it too
if sess . get ( " build_ctx " ) :
try :
sess [ " build_ctx " ] [ " locks " ] = { str ( n ) for n in locks }
except Exception :
pass
# Return a compact button HTML that flips state on next click, and an OOB last-action chip
next_state = " 0 " if want_lock else " 1 "
label = " Unlock " if want_lock else " Lock "
title = ( " Click to unlock " if want_lock else " Click to lock " )
icon = ( " 🔒 " if want_lock else " 🔓 " )
# Include data-locked to reflect the current state for client-side handler
btn = f ''' <button type= " button " class= " btn-lock " title= " { title } " data-locked= " { ' 1 ' if want_lock else ' 0 ' } "
hx - post = " /build/lock " hx - target = " closest .lock-box " hx - swap = " innerHTML "
hx - vals = ' {{ " name " : " {name} " , " locked " : " {next_state} " }} ' > { icon } { label } < / button > '''
# Compute locks count for chip
locks_count = len ( locks )
if locks_count > 0 :
chip_html = f ' <span id= " locks-chip " hx-swap-oob= " true " ><span class= " chip " title= " Locked cards " >🔒 { locks_count } locked</span></span> '
else :
chip_html = ' <span id= " locks-chip " hx-swap-oob= " true " ></span> '
# Last action chip for feedback (use hx-swap-oob)
try :
disp = ( name or ' ' ) . strip ( )
except Exception :
disp = str ( name )
action = " Locked " if want_lock else " Unlocked "
chip = (
f ' <div id= " last-action " hx-swap-oob= " true " > '
f ' <span class= " chip " title= " Click to dismiss " > { action } <strong> { disp } </strong></span> '
f ' </div> '
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
2025-08-26 09:48:25 -07:00
)
2025-08-28 14:57:22 -07:00
# If this request came from the locked-cards list and it's an unlock, remove the row inline
try :
if ( from_list is not None ) and ( not want_lock ) :
# Also update the locks-count chip, and if no locks remain, remove the whole section
extra = chip_html
if locks_count == 0 :
extra + = ' <details id= " locked-section " hx-swap-oob= " true " ></details> '
# Return empty body to delete the <li> via hx-swap=outerHTML, plus OOB updates
return HTMLResponse ( ' ' + extra )
except Exception :
pass
return HTMLResponse ( btn + chip + chip_html )
@router.get ( " /alternatives " , response_class = HTMLResponse )
2025-10-06 14:12:17 -07:00
async def build_alternatives (
request : Request ,
name : str ,
stage : str | None = None ,
owned_only : int = Query ( 0 ) ,
refresh : int = Query ( 0 ) ,
) - > HTMLResponse :
2025-09-03 18:00:06 -07:00
""" Suggest alternative cards for a given card name, preferring role-specific pools.
Strategy :
1 ) Determine the seed card ' s role from the current deck (Role field) or optional `stage` hint.
2 ) Build a candidate pool from the combined DataFrame using the same filters as the build phase
for that role ( ramp / removal / wipes / card_advantage / protection ) .
3 ) Exclude commander , lands ( where applicable ) , in - deck , locked , and the seed itself ; then sort
by edhrecRank / manaValue . Apply owned - only filter if requested .
4 ) Fall back to tag - overlap similarity when role cannot be determined or data is missing .
2025-08-28 14:57:22 -07:00
2025-09-03 18:00:06 -07:00
Returns an HTML partial listing up to ~ 10 alternatives with Replace buttons .
2025-08-28 14:57:22 -07:00
"""
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
ctx = sess . get ( " build_ctx " ) or { }
b = ctx . get ( " builder " ) if isinstance ( ctx , dict ) else None
# Owned library
2025-09-02 11:39:14 -07:00
owned_set = owned_set_helper ( )
2025-08-28 14:57:22 -07:00
require_owned = bool ( int ( owned_only or 0 ) ) or bool ( sess . get ( " use_owned_only " ) )
2025-10-06 14:12:17 -07:00
refresh_requested = bool ( int ( refresh or 0 ) )
2025-08-28 14:57:22 -07:00
# If builder context missing, show a guidance message
if not b :
2025-09-02 11:39:14 -07:00
html = ' <div class= " alts " ><div class= " muted " >Start the build to see alternatives.</div></div> '
2025-08-28 14:57:22 -07:00
return HTMLResponse ( html )
try :
2025-09-03 18:00:06 -07:00
name_disp = str ( name ) . strip ( )
name_l = name_disp . lower ( )
2025-08-28 14:57:22 -07:00
commander_l = str ( ( sess . get ( " commander " ) or " " ) ) . strip ( ) . lower ( )
locked_set = { str ( x ) . strip ( ) . lower ( ) for x in ( sess . get ( " locks " , [ ] ) or [ ] ) }
2025-09-03 18:00:06 -07:00
# Exclusions from prior inline replacements
alts_exclude = { str ( x ) . strip ( ) . lower ( ) for x in ( sess . get ( " alts_exclude " , [ ] ) or [ ] ) }
alts_exclude_v = int ( sess . get ( " alts_exclude_v " ) or 0 )
# Resolve role from stage hint or current library entry
stage_hint = ( stage or " " ) . strip ( ) . lower ( )
stage_map = {
" ramp " : " ramp " ,
" removal " : " removal " ,
" wipes " : " wipe " ,
" wipe " : " wipe " ,
" board_wipe " : " wipe " ,
" card_advantage " : " card_advantage " ,
" draw " : " card_advantage " ,
" protection " : " protection " ,
# Additional mappings for creature stages
" creature " : " creature " ,
" creatures " : " creature " ,
" primary " : " creature " ,
" secondary " : " creature " ,
2025-09-10 16:20:38 -07:00
# Land-related hints
" land " : " land " ,
" lands " : " land " ,
" utility " : " land " ,
" misc " : " land " ,
" fetch " : " land " ,
" dual " : " land " ,
2025-09-03 18:00:06 -07:00
}
hinted_role = stage_map . get ( stage_hint ) if stage_hint else None
lib = getattr ( b , " card_library " , { } ) or { }
# Case-insensitive lookup in deck library
lib_key = None
try :
if name_disp in lib :
lib_key = name_disp
else :
lm = { str ( k ) . strip ( ) . lower ( ) : k for k in lib . keys ( ) }
lib_key = lm . get ( name_l )
except Exception :
lib_key = None
entry = lib . get ( lib_key ) if lib_key else None
role = hinted_role or ( entry . get ( " Role " ) if isinstance ( entry , dict ) else None )
if isinstance ( role , str ) :
role = role . strip ( ) . lower ( )
# Build role-specific pool from combined DataFrame
items : list [ dict ] = [ ]
2025-10-06 14:12:17 -07:00
def _clean ( value : Any ) - > str :
try :
if value is None :
return " "
if isinstance ( value , float ) and value != value :
return " "
text = str ( value )
return text . strip ( )
except Exception :
return " "
def _normalize_tags ( raw : Any ) - > list [ str ] :
if not raw :
return [ ]
if isinstance ( raw , ( list , tuple , set ) ) :
return [ str ( t ) . strip ( ) for t in raw if str ( t ) . strip ( ) ]
if isinstance ( raw , str ) :
txt = raw . strip ( )
if not txt :
return [ ]
if txt . startswith ( " [ " ) and txt . endswith ( " ] " ) :
try :
import json as _json
parsed = _json . loads ( txt )
if isinstance ( parsed , list ) :
return [ str ( t ) . strip ( ) for t in parsed if str ( t ) . strip ( ) ]
except Exception :
pass
return [ s . strip ( ) for s in txt . split ( ' , ' ) if s . strip ( ) ]
return [ ]
def _meta_from_row ( row_obj : Any ) - > dict [ str , Any ] :
meta = {
" mana " : " " ,
" rarity " : " " ,
" role " : " " ,
" tags " : [ ] ,
" hover_simple " : True ,
}
if row_obj is None :
meta [ " role " ] = _clean ( used_role or " " )
return meta
def _pull ( * keys : str ) - > Any :
for key in keys :
try :
if isinstance ( row_obj , dict ) :
val = row_obj . get ( key )
elif hasattr ( row_obj , " get " ) :
val = row_obj . get ( key )
else :
val = getattr ( row_obj , key , None )
except Exception :
val = None
if val not in ( None , " " ) :
if isinstance ( val , float ) and val != val :
continue
return val
return None
meta [ " mana " ] = _clean ( _pull ( " mana_cost " , " manaCost " , " mana " , " manaValue " , " cmc " , " mv " ) )
meta [ " rarity " ] = _clean ( _pull ( " rarity " ) )
role_val = _pull ( " role " , " primaryRole " , " subRole " )
if not role_val :
role_val = used_role or " "
meta [ " role " ] = _clean ( role_val )
tags_val = _pull ( " themeTags " , " _ltags " , " tags " )
meta_tags = _normalize_tags ( tags_val )
meta [ " tags " ] = meta_tags
meta [ " hover_simple " ] = not ( meta [ " mana " ] or meta [ " rarity " ] or ( meta_tags and len ( meta_tags ) > 0 ) )
return meta
def _build_meta_map ( df_obj ) - > dict [ str , dict [ str , Any ] ] :
mapping : dict [ str , dict [ str , Any ] ] = { }
try :
if df_obj is None or not hasattr ( df_obj , " iterrows " ) :
return mapping
for _ , row in df_obj . iterrows ( ) :
try :
nm_val = str ( row . get ( " name " ) or " " ) . strip ( )
except Exception :
nm_val = " "
if not nm_val :
continue
key = nm_val . lower ( )
if key in mapping :
continue
mapping [ key ] = _meta_from_row ( row )
except Exception :
return mapping
return mapping
def _sampler ( seq : list [ str ] , limit : int ) - > list [ str ] :
if limit < = 0 :
return [ ]
if len ( seq ) < = limit :
return list ( seq )
rng = getattr ( b , " rng " , None )
try :
if rng is not None :
return rng . sample ( seq , limit ) if len ( seq ) > = limit else list ( seq )
import random as _rnd # type: ignore
return _rnd . sample ( seq , limit ) if len ( seq ) > = limit else list ( seq )
except Exception :
return list ( seq [ : limit ] )
2025-09-03 18:00:06 -07:00
used_role = role if isinstance ( role , str ) and role else None
2025-09-10 16:20:38 -07:00
# Promote to 'land' role when the seed card is a land (regardless of stored role)
try :
if entry and isinstance ( entry , dict ) :
ctype = str ( entry . get ( " Card Type " ) or entry . get ( " Type " ) or " " ) . lower ( )
if " land " in ctype :
used_role = " land "
except Exception :
pass
2025-09-03 18:00:06 -07:00
df = getattr ( b , " _combined_cards_df " , None )
# Compute current deck fingerprint to avoid stale cached alternatives after stage changes
in_deck : set [ str ] = builder_present_names ( b )
try :
import hashlib as _hl
deck_fp = _hl . md5 (
( " | " . join ( sorted ( in_deck ) ) if in_deck else " " ) . encode ( " utf-8 " )
) . hexdigest ( ) [ : 8 ]
except Exception :
deck_fp = str ( len ( in_deck ) )
# Use a cache key that includes the exclusions version and deck fingerprint
cache_key = ( name_l , commander_l , used_role or " _fallback_ " , require_owned , alts_exclude_v , deck_fp )
2025-10-06 14:12:17 -07:00
cached = None
if used_role != ' land ' and not refresh_requested :
cached = _alts_get_cached ( cache_key )
2025-08-28 14:57:22 -07:00
if cached is not None :
return HTMLResponse ( cached )
2025-09-03 18:00:06 -07:00
def _render_and_cache ( _items : list [ dict ] ) :
html_str = templates . get_template ( " build/_alternatives.html " ) . render ( {
" request " : request ,
" name " : name_disp ,
" require_owned " : require_owned ,
" items " : _items ,
} )
2025-10-06 14:12:17 -07:00
# Skip caching when used_role == land or refresh requested for per-call randomness
if used_role != ' land ' and not refresh_requested :
2025-09-10 16:20:38 -07:00
try :
_alts_set_cached ( cache_key , html_str )
except Exception :
pass
2025-09-03 18:00:06 -07:00
return HTMLResponse ( html_str )
# Helper: map display names
def _display_map_for ( lower_pool : set [ str ] ) - > dict [ str , str ] :
try :
return builder_display_map ( b , lower_pool ) # type: ignore[arg-type]
except Exception :
return { nm : nm for nm in lower_pool }
# Common exclusions
# in_deck already computed above
def _exclude ( df0 ) :
out = df0 . copy ( )
if " name " in out . columns :
out [ " _lname " ] = out [ " name " ] . astype ( str ) . str . strip ( ) . str . lower ( )
mask = ~ out [ " _lname " ] . isin ( { name_l } | in_deck | locked_set | alts_exclude | ( { commander_l } if commander_l else set ( ) ) )
out = out [ mask ]
return out
# If we have data and a recognized role, mirror the phase logic
2025-09-10 16:20:38 -07:00
if df is not None and hasattr ( df , " copy " ) and ( used_role in { " ramp " , " removal " , " wipe " , " card_advantage " , " protection " , " creature " , " land " } ) :
2025-09-03 18:00:06 -07:00
pool = df . copy ( )
try :
pool [ " _ltags " ] = pool . get ( " themeTags " , [ ] ) . apply ( bu . normalize_tag_cell )
except Exception :
# best-effort normalize
pool [ " _ltags " ] = pool . get ( " themeTags " , [ ] ) . apply ( lambda x : [ str ( t ) . strip ( ) . lower ( ) for t in ( x or [ ] ) ] if isinstance ( x , list ) else [ ] )
2025-09-10 16:20:38 -07:00
# Role-specific base filtering
if used_role != " land " :
# Exclude lands for non-land roles
if " type " in pool . columns :
pool = pool [ ~ pool [ " type " ] . fillna ( " " ) . str . contains ( " Land " , case = False , na = False ) ]
else :
# Keep only lands
if " type " in pool . columns :
pool = pool [ pool [ " type " ] . fillna ( " " ) . str . contains ( " Land " , case = False , na = False ) ]
# Seed info to guide filtering
seed_is_basic = False
try :
seed_is_basic = bool ( name_l in { b . strip ( ) . lower ( ) for b in getattr ( bc , ' BASIC_LANDS ' , [ ] ) } )
except Exception :
seed_is_basic = False
if seed_is_basic :
# For basics: show other basics (different colors) to allow quick swaps
try :
pool = pool [ pool [ ' name ' ] . astype ( str ) . str . strip ( ) . str . lower ( ) . isin ( { x . lower ( ) for x in getattr ( bc , ' BASIC_LANDS ' , [ ] ) } ) ]
except Exception :
pass
else :
# For non-basics: prefer other non-basics
try :
pool = pool [ ~ pool [ ' name ' ] . astype ( str ) . str . strip ( ) . str . lower ( ) . isin ( { x . lower ( ) for x in getattr ( bc , ' BASIC_LANDS ' , [ ] ) } ) ]
except Exception :
pass
# Apply mono-color misc land filters (no debug CSV dependency)
try :
colors = list ( getattr ( b , ' color_identity ' , [ ] ) or [ ] )
mono = len ( colors ) < = 1
mono_exclude = { n . lower ( ) for n in getattr ( bc , ' MONO_COLOR_MISC_LAND_EXCLUDE ' , [ ] ) }
mono_keep = { n . lower ( ) for n in getattr ( bc , ' MONO_COLOR_MISC_LAND_KEEP_ALWAYS ' , [ ] ) }
kindred_all = { n . lower ( ) for n in getattr ( bc , ' KINDRED_ALL_LAND_NAMES ' , [ ] ) }
any_color_phrases = [ s . lower ( ) for s in getattr ( bc , ' ANY_COLOR_MANA_PHRASES ' , [ ] ) ]
extra_rainbow_terms = [ s . lower ( ) for s in getattr ( bc , ' MONO_COLOR_RAINBOW_TEXT_EXTRA ' , [ ] ) ]
fetch_names = set ( )
for seq in getattr ( bc , ' COLOR_TO_FETCH_LANDS ' , { } ) . values ( ) :
for nm in seq :
fetch_names . add ( nm . lower ( ) )
for nm in getattr ( bc , ' GENERIC_FETCH_LANDS ' , [ ] ) :
fetch_names . add ( nm . lower ( ) )
# World Tree check needs all five colors
need_all_colors = { ' w ' , ' u ' , ' b ' , ' r ' , ' g ' }
def _illegal_world_tree ( nm : str ) - > bool :
return nm == ' the world tree ' and set ( c . lower ( ) for c in colors ) != need_all_colors
# Text column fallback
text_col = ' text '
if text_col not in pool . columns :
for c in pool . columns :
if ' text ' in c . lower ( ) :
text_col = c
break
def _exclude_row ( row ) - > bool :
nm_l = str ( row [ ' name ' ] ) . strip ( ) . lower ( )
if mono and nm_l in mono_exclude and nm_l not in mono_keep and nm_l not in kindred_all :
return True
if mono and nm_l not in mono_keep and nm_l not in kindred_all :
try :
txt = str ( row . get ( text_col , ' ' ) or ' ' ) . lower ( )
if any ( p in txt for p in any_color_phrases + extra_rainbow_terms ) :
return True
except Exception :
pass
if nm_l in fetch_names :
return True
if _illegal_world_tree ( nm_l ) :
return True
return False
pool = pool [ ~ pool . apply ( _exclude_row , axis = 1 ) ]
except Exception :
pass
# Optional sub-role filtering (only if enough depth)
try :
subrole = str ( ( entry or { } ) . get ( ' SubRole ' ) or ' ' ) . strip ( ) . lower ( )
if subrole :
# Heuristic categories for grouping
cat_map = {
' fetch ' : ' fetch ' ,
' dual ' : ' dual ' ,
' triple ' : ' triple ' ,
' misc ' : ' misc ' ,
' utility ' : ' misc ' ,
' basic ' : ' basic '
}
target_cat = None
for key , val in cat_map . items ( ) :
if key in subrole :
target_cat = val
break
if target_cat and len ( pool ) > 25 :
# Lightweight textual filter using known markers
def _cat_row ( rname : str , rtype : str ) - > str :
rl = rname . lower ( )
rt = rtype . lower ( )
if any ( k in rl for k in ( ' vista ' , ' strand ' , ' delta ' , ' mire ' , ' heath ' , ' rainforest ' , ' mesa ' , ' foothills ' , ' catacombs ' , ' tarn ' , ' flat ' , ' expanse ' , ' wilds ' , ' landscape ' , ' tunnel ' , ' terrace ' , ' vista ' ) ) :
return ' fetch '
if ' triple ' in rt or ' three ' in rt :
return ' triple '
if any ( t in rt for t in ( ' forest ' , ' plains ' , ' island ' , ' swamp ' , ' mountain ' ) ) and any ( sym in rt for sym in ( ' forest ' , ' plains ' , ' island ' , ' swamp ' , ' mountain ' ) ) and ' land ' in rt :
# Basic-check crude
return ' basic '
return ' misc '
try :
tmp = pool . copy ( )
tmp [ ' _cat ' ] = tmp . apply ( lambda r : _cat_row ( str ( r . get ( ' name ' , ' ' ) ) , str ( r . get ( ' type ' , ' ' ) ) ) , axis = 1 )
sub_pool = tmp [ tmp [ ' _cat ' ] == target_cat ]
if len ( sub_pool ) > = 10 :
pool = sub_pool . drop ( columns = [ ' _cat ' ] )
except Exception :
pass
except Exception :
pass
2025-09-03 18:00:06 -07:00
# Exclude commander explicitly
if " name " in pool . columns and commander_l :
pool = pool [ pool [ " name " ] . astype ( str ) . str . strip ( ) . str . lower ( ) != commander_l ]
# Role-specific filter
def _is_wipe ( tags : list [ str ] ) - > bool :
return any ( ( " board wipe " in t ) or ( " mass removal " in t ) for t in tags )
def _is_removal ( tags : list [ str ] ) - > bool :
return any ( ( " removal " in t ) or ( " spot removal " in t ) for t in tags )
def _is_draw ( tags : list [ str ] ) - > bool :
return any ( ( " draw " in t ) or ( " card advantage " in t ) for t in tags )
def _matches_selected ( tags : list [ str ] ) - > bool :
try :
sel = [ str ( t ) . strip ( ) . lower ( ) for t in ( sess . get ( " tags " ) or [ ] ) if str ( t ) . strip ( ) ]
if not sel :
return True
st = set ( sel )
return any ( any ( s in t for s in st ) for t in tags )
except Exception :
return True
if used_role == " ramp " :
pool = pool [ pool [ " _ltags " ] . apply ( lambda tags : any ( " ramp " in t for t in tags ) ) ]
elif used_role == " removal " :
pool = pool [ pool [ " _ltags " ] . apply ( _is_removal ) & ~ pool [ " _ltags " ] . apply ( _is_wipe ) ]
elif used_role == " wipe " :
pool = pool [ pool [ " _ltags " ] . apply ( _is_wipe ) ]
elif used_role == " card_advantage " :
pool = pool [ pool [ " _ltags " ] . apply ( _is_draw ) ]
elif used_role == " protection " :
pool = pool [ pool [ " _ltags " ] . apply ( lambda tags : any ( " protection " in t for t in tags ) ) ]
elif used_role == " creature " :
# Keep only creatures; bias toward selected theme tags when available
if " type " in pool . columns :
pool = pool [ pool [ " type " ] . fillna ( " " ) . str . contains ( " Creature " , case = False , na = False ) ]
try :
pool = pool [ pool [ " _ltags " ] . apply ( _matches_selected ) ]
except Exception :
pass
2025-09-10 16:20:38 -07:00
elif used_role == " land " :
# Already constrained to lands; no additional tag filter needed
pass
2025-09-03 18:00:06 -07:00
# Sort by priority like the builder
try :
pool = bu . sort_by_priority ( pool , [ " edhrecRank " , " manaValue " ] ) # type: ignore[arg-type]
except Exception :
pass
2025-09-10 16:20:38 -07:00
# Exclusions and ownership (for non-random roles this stays before slicing)
2025-09-03 18:00:06 -07:00
pool = _exclude ( pool )
try :
if bool ( sess . get ( " prefer_owned " ) ) and getattr ( b , " owned_card_names " , None ) :
pool = bu . prefer_owned_first ( pool , { str ( n ) . lower ( ) for n in getattr ( b , " owned_card_names " , set ( ) ) } )
except Exception :
pass
2025-10-06 14:12:17 -07:00
row_meta = _build_meta_map ( pool )
2025-09-10 16:20:38 -07:00
# Land role: random 12 from top 60-100 window
if used_role == ' land ' :
import random as _rnd
total = len ( pool )
if total == 0 :
pass
else :
cap = min ( 100 , total )
floor = min ( 60 , cap ) # if fewer than 60 just use all
if cap < = 12 :
window_size = cap
else :
if cap == floor :
window_size = cap
else :
rng_obj = getattr ( b , ' rng ' , None )
if rng_obj :
window_size = rng_obj . randint ( floor , cap )
else :
window_size = _rnd . randint ( floor , cap )
window_df = pool . head ( window_size )
names = window_df [ ' name ' ] . astype ( str ) . str . strip ( ) . tolist ( )
# Random sample up to 12 distinct names
sample_n = min ( 12 , len ( names ) )
if sample_n > 0 :
if getattr ( b , ' rng ' , None ) :
chosen = getattr ( b , ' rng ' ) . sample ( names , sample_n ) if len ( names ) > = sample_n else names
else :
chosen = _rnd . sample ( names , sample_n ) if len ( names ) > = sample_n else names
lower_map = { n . strip ( ) . lower ( ) : n for n in chosen }
display_map = _display_map_for ( set ( k for k in lower_map . keys ( ) ) )
for nm_lc , orig in lower_map . items ( ) :
is_owned = ( nm_lc in owned_set )
if require_owned and not is_owned :
continue
if nm_lc == name_l or ( in_deck and nm_lc in in_deck ) :
continue
2025-10-06 14:12:17 -07:00
meta = row_meta . get ( nm_lc ) or _meta_from_row ( None )
2025-09-10 16:20:38 -07:00
items . append ( {
' name ' : display_map . get ( nm_lc , orig ) ,
' name_lower ' : nm_lc ,
' owned ' : is_owned ,
2025-10-06 14:12:17 -07:00
' tags ' : meta . get ( ' tags ' ) or [ ] ,
' role ' : meta . get ( ' role ' , ' ' ) ,
' mana ' : meta . get ( ' mana ' , ' ' ) ,
' rarity ' : meta . get ( ' rarity ' , ' ' ) ,
' hover_simple ' : bool ( meta . get ( ' hover_simple ' , True ) ) ,
2025-09-10 16:20:38 -07:00
} )
if items :
return _render_and_cache ( items )
else :
# Default deterministic top-N (increase to 12 for parity)
lower_pool : list [ str ] = [ ]
try :
lower_pool = pool [ " name " ] . astype ( str ) . str . strip ( ) . str . lower ( ) . tolist ( )
except Exception :
lower_pool = [ ]
display_map = _display_map_for ( set ( lower_pool ) )
2025-10-06 14:12:17 -07:00
iteration_order = lower_pool
if refresh_requested and len ( lower_pool ) > 12 :
window_size = min ( len ( lower_pool ) , 30 )
window = lower_pool [ : window_size ]
sampled = _sampler ( window , min ( window_size , 12 ) )
seen_sampled = set ( sampled )
iteration_order = sampled + [ nm for nm in lower_pool if nm not in seen_sampled ]
for nm_l in iteration_order :
2025-09-10 16:20:38 -07:00
is_owned = ( nm_l in owned_set )
if require_owned and not is_owned :
continue
if nm_l == name_l or ( in_deck and nm_l in in_deck ) :
continue
2025-10-06 14:12:17 -07:00
meta = row_meta . get ( nm_l ) or _meta_from_row ( None )
2025-09-10 16:20:38 -07:00
items . append ( {
" name " : display_map . get ( nm_l , nm_l ) ,
" name_lower " : nm_l ,
" owned " : is_owned ,
2025-10-06 14:12:17 -07:00
" tags " : meta . get ( " tags " ) or [ ] ,
" role " : meta . get ( " role " , " " ) ,
" mana " : meta . get ( " mana " , " " ) ,
" rarity " : meta . get ( " rarity " , " " ) ,
" hover_simple " : bool ( meta . get ( " hover_simple " , True ) ) ,
2025-09-10 16:20:38 -07:00
} )
if len ( items ) > = 12 :
break
if items :
return _render_and_cache ( items )
2025-09-03 18:00:06 -07:00
# Fallback: tag-similarity suggestions (previous behavior)
2025-08-28 14:57:22 -07:00
tags_idx = getattr ( b , " _card_name_tags_index " , { } ) or { }
seed_tags = set ( tags_idx . get ( name_l ) or [ ] )
all_names = set ( tags_idx . keys ( ) )
2025-09-02 11:39:14 -07:00
candidates : list [ tuple [ str , int ] ] = [ ] # (name, score)
2025-08-28 14:57:22 -07:00
for nm in all_names :
if nm == name_l :
continue
if commander_l and nm == commander_l :
continue
if in_deck and nm in in_deck :
continue
if locked_set and nm in locked_set :
continue
2025-09-03 18:00:06 -07:00
if nm in alts_exclude :
continue
2025-08-28 14:57:22 -07:00
tgs = set ( tags_idx . get ( nm ) or [ ] )
score = len ( seed_tags & tgs )
if score < = 0 :
continue
candidates . append ( ( nm , score ) )
2025-09-03 18:00:06 -07:00
# If no tag-based candidates, try shared trigger tag from library entry
if not candidates and isinstance ( entry , dict ) :
2025-08-28 14:57:22 -07:00
try :
2025-09-03 18:00:06 -07:00
trig = str ( entry . get ( " TriggerTag " ) or " " ) . strip ( ) . lower ( )
2025-08-28 14:57:22 -07:00
except Exception :
trig = " "
if trig :
for nm , tglist in tags_idx . items ( ) :
if nm == name_l :
continue
if nm in { str ( k ) . strip ( ) . lower ( ) for k in lib . keys ( ) } :
continue
if trig in { str ( t ) . strip ( ) . lower ( ) for t in ( tglist or [ ] ) } :
candidates . append ( ( nm , 1 ) )
def _owned ( nm : str ) - > bool :
return nm in owned_set
candidates . sort ( key = lambda x : ( - x [ 1 ] , 0 if _owned ( x [ 0 ] ) else 1 , x [ 0 ] ) )
2025-10-06 14:12:17 -07:00
if refresh_requested and len ( candidates ) > 1 :
name_sequence = [ nm for nm , _score in candidates ]
sampled_names = _sampler ( name_sequence , min ( len ( name_sequence ) , 10 ) )
sampled_set = set ( sampled_names )
reordered : list [ tuple [ str , int ] ] = [ ]
for nm in sampled_names :
for cand_nm , cand_score in candidates :
if cand_nm == nm :
reordered . append ( ( cand_nm , cand_score ) )
break
for cand_nm , cand_score in candidates :
if cand_nm not in sampled_set :
reordered . append ( ( cand_nm , cand_score ) )
candidates = reordered
2025-09-02 11:39:14 -07:00
pool_lower = { nm for ( nm , _s ) in candidates }
2025-09-03 18:00:06 -07:00
display_map = _display_map_for ( pool_lower )
2025-08-28 14:57:22 -07:00
seen = set ( )
for nm , score in candidates :
if nm in seen :
continue
seen . add ( nm )
is_owned = ( nm in owned_set )
if require_owned and not is_owned :
continue
2025-09-02 11:39:14 -07:00
items . append ( {
2025-09-03 18:00:06 -07:00
" name " : display_map . get ( nm , nm ) ,
2025-09-02 11:39:14 -07:00
" name_lower " : nm ,
" owned " : is_owned ,
" tags " : list ( tags_idx . get ( nm ) or [ ] ) ,
2025-10-06 14:12:17 -07:00
" role " : " " ,
" mana " : " " ,
" rarity " : " " ,
" hover_simple " : True ,
2025-09-02 11:39:14 -07:00
} )
if len ( items ) > = 10 :
2025-08-28 14:57:22 -07:00
break
2025-09-03 18:00:06 -07:00
return _render_and_cache ( items )
2025-08-28 14:57:22 -07:00
except Exception as e :
return HTMLResponse ( f ' <div class= " alts " ><div class= " muted " >No alternatives: { e } </div></div> ' )
@router.post ( " /replace " , response_class = HTMLResponse )
2025-10-06 14:12:17 -07:00
async def build_replace ( request : Request , old : str = Form ( . . . ) , new : str = Form ( . . . ) , owned_only : str = Form ( " 0 " ) ) - > HTMLResponse :
2025-09-03 18:00:06 -07:00
""" Inline replace: swap `old` with `new` in the current builder when possible, and suppress `old` from future alternatives.
2025-08-28 14:57:22 -07:00
2025-09-03 18:00:06 -07:00
Falls back to lock - and - rerun guidance if no active builder is present .
2025-08-28 14:57:22 -07:00
"""
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
2025-09-03 18:00:06 -07:00
o_disp = str ( old ) . strip ( )
n_disp = str ( new ) . strip ( )
o = o_disp . lower ( )
n = n_disp . lower ( )
2025-10-06 14:12:17 -07:00
owned_only_flag = str ( owned_only or " " ) . strip ( ) . lower ( )
owned_only_int = 1 if owned_only_flag in { " 1 " , " true " , " yes " , " on " } else 0
2025-09-03 18:00:06 -07:00
# Maintain locks to bias future picks and enforcement
2025-08-28 14:57:22 -07:00
locks = set ( sess . get ( " locks " , [ ] ) )
locks . discard ( o )
locks . add ( n )
sess [ " locks " ] = list ( locks )
# Track last replace for optional undo
try :
sess [ " last_replace " ] = { " old " : o , " new " : n }
except Exception :
pass
2025-09-03 18:00:06 -07:00
ctx = sess . get ( " build_ctx " ) or { }
try :
ctx [ " locks " ] = { str ( x ) for x in locks }
except Exception :
pass
# Record preferred replacements
try :
pref = ctx . get ( " preferred_replacements " ) if isinstance ( ctx , dict ) else None
if not isinstance ( pref , dict ) :
pref = { }
ctx [ " preferred_replacements " ] = pref
pref [ o ] = n
except Exception :
pass
b : DeckBuilder | None = ctx . get ( " builder " ) if isinstance ( ctx , dict ) else None
if b is not None :
2025-08-28 14:57:22 -07:00
try :
2025-09-03 18:00:06 -07:00
lib = getattr ( b , " card_library " , { } ) or { }
# Find the exact key for `old` in a case-insensitive manner
old_key = None
if o_disp in lib :
old_key = o_disp
else :
for k in list ( lib . keys ( ) ) :
if str ( k ) . strip ( ) . lower ( ) == o :
old_key = k
break
if old_key is None :
raise KeyError ( " old card not in deck " )
old_info = dict ( lib . get ( old_key ) or { } )
role = str ( old_info . get ( " Role " ) or " " ) . strip ( )
subrole = str ( old_info . get ( " SubRole " ) or " " ) . strip ( )
try :
count = int ( old_info . get ( " Count " , 1 ) )
except Exception :
count = 1
# Remove old entry
try :
del lib [ old_key ]
except Exception :
pass
# Resolve canonical name and info for new
df = getattr ( b , " _combined_cards_df " , None )
new_key = n_disp
card_type = " "
mana_cost = " "
trigger_tag = str ( old_info . get ( " TriggerTag " ) or " " )
if df is not None :
try :
row = df [ df [ " name " ] . astype ( str ) . str . strip ( ) . str . lower ( ) == n ]
if not row . empty :
new_key = str ( row . iloc [ 0 ] [ " name " ] ) or n_disp
card_type = str ( row . iloc [ 0 ] . get ( " type " , row . iloc [ 0 ] . get ( " type_line " , " " ) ) or " " )
mana_cost = str ( row . iloc [ 0 ] . get ( " mana_cost " , row . iloc [ 0 ] . get ( " manaCost " , " " ) ) or " " )
except Exception :
pass
lib [ new_key ] = {
" Count " : count ,
" Card Type " : card_type ,
" Mana Cost " : mana_cost ,
" Role " : role ,
" SubRole " : subrole ,
" AddedBy " : " Replace " ,
" TriggerTag " : trigger_tag ,
}
# Mirror preferred replacements onto the builder for enforcement
try :
cur = getattr ( b , " preferred_replacements " , { } ) or { }
cur [ str ( o ) ] = str ( n )
setattr ( b , " preferred_replacements " , cur )
except Exception :
pass
# Update alternatives exclusion set and bump version to invalidate caches
try :
ex = { str ( x ) . strip ( ) . lower ( ) for x in ( sess . get ( " alts_exclude " , [ ] ) or [ ] ) }
ex . add ( o )
sess [ " alts_exclude " ] = list ( ex )
sess [ " alts_exclude_v " ] = int ( sess . get ( " alts_exclude_v " ) or 0 ) + 1
except Exception :
pass
# Success panel and OOB updates (refresh compliance panel)
# Compute ownership of the new card for UI badge update
is_owned = ( n in owned_set_helper ( ) )
2025-10-06 14:12:17 -07:00
refresh = (
' <div hx-get= " /build/alternatives?name= '
+ quote_plus ( new_key )
+ f ' &owned_only= { owned_only_int } " hx-trigger= " load delay:80ms " '
' hx-target= " closest .alts " hx-swap= " outerHTML " aria-hidden= " true " ></div> '
)
2025-09-03 18:00:06 -07:00
html = (
' <div class= " alts " style= " margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115; " > '
f ' <div>Replaced <strong> { o_disp } </strong> with <strong> { new_key } </strong>.</div> '
' <div class= " muted " style= " margin-top:.35rem; " >Compliance panel will refresh.</div> '
' <div style= " margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; " > '
' <button type= " button " class= " btn " onclick= " try { this.closest( \' .alts \' ).remove();}catch(_) {} " >Close</button> '
' </div> '
2025-10-06 14:12:17 -07:00
+ refresh +
2025-09-03 18:00:06 -07:00
' </div> '
)
# Inline mutate the nearest card tile to reflect the new card without a rerun
mutator = """
< script >
( function ( ) {
try {
var panel = document . currentScript & & document . currentScript . previousElementSibling & & document . currentScript . previousElementSibling . classList & & document . currentScript . previousElementSibling . classList . contains ( ' alts ' ) ? document . currentScript . previousElementSibling : null ;
if ( ! panel ) { return ; }
var oldName = panel . getAttribute ( ' data-old ' ) | | ' ' ;
var newName = panel . getAttribute ( ' data-new ' ) | | ' ' ;
var isOwned = panel . getAttribute ( ' data-owned ' ) == = ' 1 ' ;
var isLocked = panel . getAttribute ( ' data-locked ' ) == = ' 1 ' ;
var tile = panel . closest ( ' .card-tile ' ) ;
if ( ! tile ) return ;
tile . setAttribute ( ' data-card-name ' , newName ) ;
var img = tile . querySelector ( ' img.card-thumb ' ) ;
if ( img ) {
var base = ' https://api.scryfall.com/cards/named?fuzzy= ' + encodeURIComponent ( newName ) + ' &format=image&version= ' ;
img . src = base + ' normal ' ;
img . setAttribute ( ' srcset ' ,
' https://api.scryfall.com/cards/named?fuzzy= ' + encodeURIComponent ( newName ) + ' &format=image&version=small 160w, ' +
' https://api.scryfall.com/cards/named?fuzzy= ' + encodeURIComponent ( newName ) + ' &format=image&version=normal 488w, ' +
' https://api.scryfall.com/cards/named?fuzzy= ' + encodeURIComponent ( newName ) + ' &format=image&version=large 672w '
) ;
img . setAttribute ( ' alt ' , newName + ' image ' ) ;
img . setAttribute ( ' data-card-name ' , newName ) ;
}
var nameEl = tile . querySelector ( ' .name ' ) ;
if ( nameEl ) { nameEl . textContent = newName ; }
var own = tile . querySelector ( ' .owned-badge ' ) ;
if ( own ) {
own . textContent = isOwned ? ' ✔ ' : ' ✖ ' ;
own . title = isOwned ? ' Owned ' : ' Not owned ' ;
tile . setAttribute ( ' data-owned ' , isOwned ? ' 1 ' : ' 0 ' ) ;
}
tile . classList . toggle ( ' locked ' , isLocked ) ;
var imgBtn = tile . querySelector ( ' .img-btn ' ) ;
if ( imgBtn ) {
try {
var valsAttr = imgBtn . getAttribute ( ' hx-vals ' ) | | ' {} ' ;
var obj = JSON . parse ( valsAttr . replace ( / & quot ; / g , ' " ' ) ) ;
obj . name = newName ;
imgBtn . setAttribute ( ' hx-vals ' , JSON . stringify ( obj ) ) ;
} catch ( e ) { }
}
var lockBtn = tile . querySelector ( ' .lock-box .btn-lock ' ) ;
if ( lockBtn ) {
try {
var v = lockBtn . getAttribute ( ' hx-vals ' ) | | ' {} ' ;
var o = JSON . parse ( v . replace ( / & quot ; / g , ' " ' ) ) ;
o . name = newName ;
lockBtn . setAttribute ( ' hx-vals ' , JSON . stringify ( o ) ) ;
} catch ( e ) { }
}
} catch ( _ ) { }
} ) ( ) ;
< / script >
"""
chip = (
f ' <div id= " last-action " hx-swap-oob= " true " > '
f ' <span class= " chip " title= " Click to dismiss " >Replaced <strong> { o_disp } </strong> → <strong> { new_key } </strong></span> '
f ' </div> '
)
# OOB fetch to refresh compliance panel
refresher = (
' <div hx-get= " /build/compliance " hx-target= " #compliance-panel " hx-swap= " outerHTML " '
' hx-trigger= " load " hx-swap-oob= " true " ></div> '
)
# Include data attributes on the panel div for the mutator script
data_owned = ' 1 ' if is_owned else ' 0 '
data_locked = ' 1 ' if ( n in locks ) else ' 0 '
prefix = ' <div class= " alts " '
replacement = (
' <div class= " alts " '
+ ' data-old= " ' + _esc ( o_disp ) + ' " '
+ ' data-new= " ' + _esc ( new_key ) + ' " '
+ ' data-owned= " ' + data_owned + ' " '
+ ' data-locked= " ' + data_locked + ' " '
)
html = html . replace ( prefix , replacement , 1 )
return HTMLResponse ( html + mutator + chip + refresher )
2025-08-28 14:57:22 -07:00
except Exception :
2025-09-03 18:00:06 -07:00
# Fall back to rerun guidance if inline swap fails
2025-08-28 14:57:22 -07:00
pass
2025-09-03 18:00:06 -07:00
# Fallback: advise rerun
2025-08-28 14:57:22 -07:00
hint = (
' <div class= " alts " style= " margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115; " > '
f ' <div>Locked <strong> { new } </strong> and unlocked <strong> { old } </strong>.</div> '
2025-09-03 18:00:06 -07:00
' <div class= " muted " style= " margin-top:.35rem; " >Now click <em>Rerun Stage</em> with Replace: On to apply this change.</div> '
2025-08-28 14:57:22 -07:00
' <div style= " margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; " > '
' <form hx-post= " /build/step5/rerun " hx-target= " #wizard " hx-swap= " innerHTML " style= " display:inline; " > '
' <input type= " hidden " name= " show_skipped " value= " 1 " /> '
' <button type= " submit " class= " btn-rerun " >Rerun stage</button> '
' </form> '
2025-09-03 18:00:06 -07:00
' <form hx-post= " /build/replace/undo " hx-target= " closest .alts " hx-swap= " outerHTML " style= " display:inline; margin:0; " > '
f ' <input type= " hidden " name= " old " value= " { old } " /> '
f ' <input type= " hidden " name= " new " value= " { new } " /> '
' <button type= " submit " class= " btn " title= " Undo this replace " >Undo</button> '
' </form> '
2025-08-28 14:57:22 -07:00
' <button type= " button " class= " btn " onclick= " try { this.closest( \' .alts \' ).remove();}catch(_) {} " >Close</button> '
' </div> '
' </div> '
)
chip = (
f ' <div id= " last-action " hx-swap-oob= " true " > '
f ' <span class= " chip " title= " Click to dismiss " >Replaced <strong> { old } </strong> → <strong> { new } </strong></span> '
f ' </div> '
)
2025-09-03 18:00:06 -07:00
# Also add old to exclusions and bump version for future alt calls
try :
ex = { str ( x ) . strip ( ) . lower ( ) for x in ( sess . get ( " alts_exclude " , [ ] ) or [ ] ) }
ex . add ( o )
sess [ " alts_exclude " ] = list ( ex )
sess [ " alts_exclude_v " ] = int ( sess . get ( " alts_exclude_v " ) or 0 ) + 1
except Exception :
pass
2025-08-28 14:57:22 -07:00
return HTMLResponse ( hint + chip )
@router.post ( " /replace/undo " , response_class = HTMLResponse )
async def build_replace_undo ( request : Request , old : str = Form ( None ) , new : str = Form ( None ) ) - > HTMLResponse :
""" Undo the last replace by restoring the previous lock state (best-effort). """
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
last = sess . get ( " last_replace " ) or { }
try :
# Prefer provided args, else fallback to last recorded
o = ( str ( old ) . strip ( ) . lower ( ) if old else str ( last . get ( " old " ) or " " ) ) . strip ( )
n = ( str ( new ) . strip ( ) . lower ( ) if new else str ( last . get ( " new " ) or " " ) ) . strip ( )
except Exception :
o , n = " " , " "
locks = set ( sess . get ( " locks " , [ ] ) )
changed = False
if n and n in locks :
locks . discard ( n )
changed = True
if o :
locks . add ( o )
changed = True
sess [ " locks " ] = list ( locks )
if sess . get ( " build_ctx " ) :
try :
sess [ " build_ctx " ] [ " locks " ] = { str ( x ) for x in locks }
except Exception :
pass
# Clear last_replace after undo
try :
if sess . get ( " last_replace " ) :
del sess [ " last_replace " ]
except Exception :
pass
# Return confirmation panel and OOB chip
msg = ' Undid replace ' if changed else ' No changes to undo '
html = (
' <div class= " alts " style= " margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115; " > '
f ' <div> { msg } .</div> '
' <div class= " muted " style= " margin-top:.35rem; " >Rerun the stage to recompute picks if needed.</div> '
' <div style= " margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; " > '
' <form hx-post= " /build/step5/rerun " hx-target= " #wizard " hx-swap= " innerHTML " style= " display:inline; " > '
' <input type= " hidden " name= " show_skipped " value= " 1 " /> '
' <button type= " submit " class= " btn-rerun " >Rerun stage</button> '
' </form> '
' <button type= " button " class= " btn " onclick= " try { this.closest( \' .alts \' ).remove();}catch(_) {} " >Close</button> '
' </div> '
' </div> '
)
chip = (
f ' <div id= " last-action " hx-swap-oob= " true " > '
f ' <span class= " chip " title= " Click to dismiss " > { msg } </span> '
f ' </div> '
)
return HTMLResponse ( html + chip )
@router.get ( " /compare " )
async def build_compare ( runA : str , runB : str ) :
""" Stub: return empty diffs; later we can diff summary files under deck_files. """
return JSONResponse ( { " ok " : True , " added " : [ ] , " removed " : [ ] , " changed " : [ ] } )
2025-09-03 18:00:06 -07:00
@router.get ( " /compliance " , response_class = HTMLResponse )
async def build_compliance_panel ( request : Request ) - > HTMLResponse :
""" Render a live Bracket compliance panel with manual enforcement controls.
Computes compliance against the current builder state without exporting , attaches a non - destructive
enforcement plan ( swaps with added = None ) when FAIL , and returns a reusable HTML partial .
Returns empty content when no active build context exists .
"""
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
ctx = sess . get ( " build_ctx " ) or { }
b : DeckBuilder | None = ctx . get ( " builder " ) if isinstance ( ctx , dict ) else None
if not b :
return HTMLResponse ( " " )
# Compute compliance snapshot in-memory and attach planning preview
comp = None
try :
if hasattr ( b , ' compute_and_print_compliance ' ) :
comp = b . compute_and_print_compliance ( base_stem = None ) # type: ignore[attr-defined]
except Exception :
comp = None
try :
if comp :
from . . services import orchestrator as orch
comp = orch . _attach_enforcement_plan ( b , comp ) # type: ignore[attr-defined]
except Exception :
pass
if not comp :
return HTMLResponse ( " " )
# Build flagged metadata (role, owned) for visual tiles and role-aware alternatives
# For combo violations, expand pairs into individual cards (exclude commander) so each can be replaced.
flagged_meta : list [ dict ] = [ ]
try :
cats = comp . get ( ' categories ' ) or { }
owned_lower = owned_set_helper ( )
lib = getattr ( b , ' card_library ' , { } ) or { }
commander_l = str ( ( sess . get ( ' commander ' ) or ' ' ) ) . strip ( ) . lower ( )
# map category key -> display label
labels = {
' game_changers ' : ' Game Changers ' ,
' extra_turns ' : ' Extra Turns ' ,
' mass_land_denial ' : ' Mass Land Denial ' ,
' tutors_nonland ' : ' Nonland Tutors ' ,
' two_card_combos ' : ' Two-Card Combos ' ,
}
seen_lower : set [ str ] = set ( )
for key , cat in cats . items ( ) :
try :
2025-09-04 19:28:48 -07:00
status = str ( cat . get ( ' status ' ) or ' ' ) . upper ( )
# Only surface tiles for WARN and FAIL
if status not in { " WARN " , " FAIL " } :
2025-09-03 18:00:06 -07:00
continue
# For two-card combos, split pairs into individual cards and skip commander
2025-09-04 19:28:48 -07:00
if key == ' two_card_combos ' and status == ' FAIL ' :
2025-09-03 18:00:06 -07:00
# Prefer the structured combos list to ensure we only expand counted pairs
pairs = [ ]
try :
for p in ( comp . get ( ' combos ' ) or [ ] ) :
if p . get ( ' cheap_early ' ) :
pairs . append ( ( str ( p . get ( ' a ' ) or ' ' ) . strip ( ) , str ( p . get ( ' b ' ) or ' ' ) . strip ( ) ) )
except Exception :
pairs = [ ]
# Fallback to parsing flagged strings like "A + B"
if not pairs :
try :
for s in ( cat . get ( ' flagged ' ) or [ ] ) :
if not isinstance ( s , str ) :
continue
parts = [ x . strip ( ) for x in s . split ( ' + ' ) if x and x . strip ( ) ]
if len ( parts ) == 2 :
pairs . append ( ( parts [ 0 ] , parts [ 1 ] ) )
except Exception :
pass
for a , bname in pairs :
for nm in ( a , bname ) :
if not nm :
continue
nm_l = nm . strip ( ) . lower ( )
if nm_l == commander_l :
# Don't prompt replacing the commander
continue
if nm_l in seen_lower :
continue
seen_lower . add ( nm_l )
entry = lib . get ( nm ) or lib . get ( nm_l ) or lib . get ( str ( nm ) . strip ( ) ) or { }
role = entry . get ( ' Role ' ) or ' '
flagged_meta . append ( {
' name ' : nm ,
' category ' : labels . get ( key , key . replace ( ' _ ' , ' ' ) . title ( ) ) ,
' role ' : role ,
' owned ' : ( nm_l in owned_lower ) ,
2025-09-04 19:28:48 -07:00
' severity ' : status ,
2025-09-03 18:00:06 -07:00
} )
continue
# Default handling for list/tag categories
names = [ n for n in ( cat . get ( ' flagged ' ) or [ ] ) if isinstance ( n , str ) ]
for nm in names :
nm_l = str ( nm ) . strip ( ) . lower ( )
if nm_l in seen_lower :
continue
seen_lower . add ( nm_l )
entry = lib . get ( nm ) or lib . get ( str ( nm ) . strip ( ) ) or lib . get ( nm_l ) or { }
role = entry . get ( ' Role ' ) or ' '
flagged_meta . append ( {
' name ' : nm ,
' category ' : labels . get ( key , key . replace ( ' _ ' , ' ' ) . title ( ) ) ,
' role ' : role ,
' owned ' : ( nm_l in owned_lower ) ,
2025-09-04 19:28:48 -07:00
' severity ' : status ,
2025-09-03 18:00:06 -07:00
} )
except Exception :
continue
except Exception :
flagged_meta = [ ]
# Render partial
ctx2 = { " request " : request , " compliance " : comp , " flagged_meta " : flagged_meta }
return templates . TemplateResponse ( " build/_compliance_panel.html " , ctx2 )
@router.post ( " /enforce/apply " , response_class = HTMLResponse )
async def build_enforce_apply ( request : Request ) - > HTMLResponse :
""" Apply bracket enforcement now using current locks as user guidance.
This adds lock placeholders if needed , runs enforcement + re - export , reloads compliance , and re - renders Step 5.
"""
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
# Ensure build context exists
ctx = sess . get ( " build_ctx " ) or { }
b : DeckBuilder | None = ctx . get ( " builder " ) if isinstance ( ctx , dict ) else None
if not b :
# No active build: show Step 5 with an error
err_ctx = step5_error_ctx ( request , sess , " No active build context to enforce. " )
resp = templates . TemplateResponse ( " build/_step5.html " , err_ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : err_ctx . get ( " summary_token " , 0 ) } } )
2025-09-03 18:00:06 -07:00
return resp
# Ensure we have a CSV base stem for consistent re-exports
base_stem = None
try :
csv_path = ctx . get ( " csv_path " )
if isinstance ( csv_path , str ) and csv_path :
import os as _os
base_stem = _os . path . splitext ( _os . path . basename ( csv_path ) ) [ 0 ]
except Exception :
base_stem = None
# If missing, export once to establish base
if not base_stem :
try :
ctx [ " csv_path " ] = b . export_decklist_csv ( ) # type: ignore[attr-defined]
import os as _os
base_stem = _os . path . splitext ( _os . path . basename ( ctx [ " csv_path " ] ) ) [ 0 ]
# Also produce a text export for completeness
ctx [ " txt_path " ] = b . export_decklist_text ( filename = base_stem + ' .txt ' ) # type: ignore[attr-defined]
except Exception :
base_stem = None
# Add lock placeholders into the library before enforcement so user choices are present
try :
locks = { str ( x ) . strip ( ) . lower ( ) for x in ( sess . get ( " locks " , [ ] ) or [ ] ) }
if locks :
df = getattr ( b , " _combined_cards_df " , None )
lib_l = { str ( n ) . strip ( ) . lower ( ) for n in getattr ( b , ' card_library ' , { } ) . keys ( ) }
for lname in locks :
if lname in lib_l :
continue
target_name = None
card_type = ' '
mana_cost = ' '
try :
if df is not None and not df . empty :
row = df [ df [ ' name ' ] . astype ( str ) . str . lower ( ) == lname ]
if not row . empty :
target_name = str ( row . iloc [ 0 ] [ ' name ' ] )
card_type = str ( row . iloc [ 0 ] . get ( ' type ' , row . iloc [ 0 ] . get ( ' type_line ' , ' ' ) ) or ' ' )
mana_cost = str ( row . iloc [ 0 ] . get ( ' mana_cost ' , row . iloc [ 0 ] . get ( ' manaCost ' , ' ' ) ) or ' ' )
except Exception :
target_name = None
if target_name :
b . card_library [ target_name ] = {
' Count ' : 1 ,
' Card Type ' : card_type ,
' Mana Cost ' : mana_cost ,
' Role ' : ' Locked ' ,
' SubRole ' : ' ' ,
' AddedBy ' : ' Lock ' ,
' TriggerTag ' : ' ' ,
}
except Exception :
pass
# Thread preferred replacements from context onto builder so enforcement can honor them
try :
pref = ctx . get ( " preferred_replacements " ) if isinstance ( ctx , dict ) else None
if isinstance ( pref , dict ) :
setattr ( b , ' preferred_replacements ' , dict ( pref ) )
except Exception :
pass
# Run enforcement + re-exports (tops up to 100 internally)
try :
rep = b . enforce_and_reexport ( base_stem = base_stem , mode = ' auto ' ) # type: ignore[attr-defined]
except Exception as e :
err_ctx = step5_error_ctx ( request , sess , f " Enforcement failed: { e } " )
resp = templates . TemplateResponse ( " build/_step5.html " , err_ctx )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : err_ctx . get ( " summary_token " , 0 ) } } )
2025-09-03 18:00:06 -07:00
return resp
# Reload compliance JSON and summary
compliance = None
try :
if base_stem :
import os as _os
import json as _json
comp_path = _os . path . join ( ' deck_files ' , f " { base_stem } _compliance.json " )
if _os . path . exists ( comp_path ) :
with open ( comp_path , ' r ' , encoding = ' utf-8 ' ) as _cf :
compliance = _json . load ( _cf )
except Exception :
compliance = None
# Rebuild Step 5 context (done state)
# Ensure csv/txt paths on ctx reflect current base
try :
import os as _os
ctx [ " csv_path " ] = _os . path . join ( ' deck_files ' , f " { base_stem } .csv " ) if base_stem else ctx . get ( " csv_path " )
ctx [ " txt_path " ] = _os . path . join ( ' deck_files ' , f " { base_stem } .txt " ) if base_stem else ctx . get ( " txt_path " )
except Exception :
pass
# Compute total_cards
try :
total_cards = 0
for _n , _e in getattr ( b , ' card_library ' , { } ) . items ( ) :
try :
total_cards + = int ( _e . get ( ' Count ' , 1 ) )
except Exception :
total_cards + = 1
except Exception :
total_cards = None
res = {
" done " : True ,
" label " : " Complete " ,
" log_delta " : " " ,
" idx " : len ( ctx . get ( " stages " , [ ] ) or [ ] ) ,
" total " : len ( ctx . get ( " stages " , [ ] ) or [ ] ) ,
" csv_path " : ctx . get ( " csv_path " ) ,
" txt_path " : ctx . get ( " txt_path " ) ,
" summary " : getattr ( b , ' build_deck_summary ' , lambda : None ) ( ) ,
" total_cards " : total_cards ,
" added_total " : 0 ,
" compliance " : compliance or rep ,
}
page_ctx = step5_ctx_from_result ( request , sess , res , status_text = " Build complete " , show_skipped = True )
2025-09-12 10:50:57 -07:00
resp = templates . TemplateResponse ( request , " build/_step5.html " , page_ctx )
2025-09-03 18:00:06 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : page_ctx . get ( " summary_token " , 0 ) } } )
2025-09-03 18:00:06 -07:00
return resp
@router.get ( " /enforcement " , response_class = HTMLResponse )
async def build_enforcement_fullpage ( request : Request ) - > HTMLResponse :
""" Full-page enforcement review: show compliance panel with swaps and controls. """
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
ctx = sess . get ( " build_ctx " ) or { }
b : DeckBuilder | None = ctx . get ( " builder " ) if isinstance ( ctx , dict ) else None
if not b :
# No active build
base = step5_empty_ctx ( request , sess )
resp = templates . TemplateResponse ( " build/_step5.html " , base )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
# Compute compliance snapshot and attach planning preview
comp = None
try :
if hasattr ( b , ' compute_and_print_compliance ' ) :
comp = b . compute_and_print_compliance ( base_stem = None ) # type: ignore[attr-defined]
except Exception :
comp = None
try :
if comp :
from . . services import orchestrator as orch
comp = orch . _attach_enforcement_plan ( b , comp ) # type: ignore[attr-defined]
except Exception :
pass
2025-10-08 11:38:30 -07:00
try :
summary_token = int ( sess . get ( " step5_summary_token " , 0 ) )
except Exception :
summary_token = 0
ctx2 = { " request " : request , " compliance " : comp , " summary_token " : summary_token }
2025-09-12 10:50:57 -07:00
resp = templates . TemplateResponse ( request , " build/enforcement.html " , ctx2 )
2025-09-03 18:00:06 -07:00
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
2025-10-08 11:38:30 -07:00
_merge_hx_trigger ( resp , { " step5:refresh " : { " token " : ctx2 . get ( " summary_token " , 0 ) } } )
2025-09-03 18:00:06 -07:00
return resp
2025-08-28 14:57:22 -07:00
@router.get ( " /permalink " )
async def build_permalink ( request : Request ) :
""" Return a URL-safe JSON payload representing current run config (basic). """
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
payload = {
" commander " : sess . get ( " commander " ) ,
" tags " : sess . get ( " tags " , [ ] ) ,
" bracket " : sess . get ( " bracket " ) ,
" ideals " : sess . get ( " ideals " ) ,
" tag_mode " : sess . get ( " tag_mode " , " AND " ) ,
" flags " : {
" owned_only " : bool ( sess . get ( " use_owned_only " ) ) ,
" prefer_owned " : bool ( sess . get ( " prefer_owned " ) ) ,
2025-10-02 15:31:05 -07:00
" swap_mdfc_basics " : bool ( sess . get ( " swap_mdfc_basics " ) ) ,
2025-08-28 14:57:22 -07:00
} ,
" locks " : list ( sess . get ( " locks " , [ ] ) ) ,
}
2025-09-17 13:23:27 -07:00
# Optional: random build fields (if present in session)
try :
rb = sess . get ( " random_build " ) or { }
if rb :
# Only include known keys to avoid leaking unrelated session data
2025-09-26 18:15:52 -07:00
inc : dict [ str , Any ] = { }
for key in ( " seed " , " theme " , " constraints " , " primary_theme " , " secondary_theme " , " tertiary_theme " ) :
if rb . get ( key ) is not None :
inc [ key ] = rb . get ( key )
resolved_list = rb . get ( " resolved_themes " )
if isinstance ( resolved_list , list ) :
inc [ " resolved_themes " ] = list ( resolved_list )
resolved_info = rb . get ( " resolved_theme_info " )
if isinstance ( resolved_info , dict ) :
inc [ " resolved_theme_info " ] = dict ( resolved_info )
if rb . get ( " combo_fallback " ) is not None :
inc [ " combo_fallback " ] = bool ( rb . get ( " combo_fallback " ) )
if rb . get ( " synergy_fallback " ) is not None :
inc [ " synergy_fallback " ] = bool ( rb . get ( " synergy_fallback " ) )
if rb . get ( " fallback_reason " ) is not None :
inc [ " fallback_reason " ] = rb . get ( " fallback_reason " )
requested = rb . get ( " requested_themes " )
if isinstance ( requested , dict ) :
inc [ " requested_themes " ] = dict ( requested )
if rb . get ( " auto_fill_enabled " ) is not None :
inc [ " auto_fill_enabled " ] = bool ( rb . get ( " auto_fill_enabled " ) )
if rb . get ( " auto_fill_applied " ) is not None :
inc [ " auto_fill_applied " ] = bool ( rb . get ( " auto_fill_applied " ) )
auto_filled = rb . get ( " auto_filled_themes " )
if isinstance ( auto_filled , list ) :
inc [ " auto_filled_themes " ] = list ( auto_filled )
display = rb . get ( " display_themes " )
if isinstance ( display , list ) :
inc [ " display_themes " ] = list ( display )
2025-09-17 13:23:27 -07:00
if inc :
payload [ " random " ] = inc
except Exception :
pass
2025-09-09 09:36:17 -07:00
2025-09-09 18:15:30 -07:00
# Add include/exclude cards and advanced options if feature is enabled
if ALLOW_MUST_HAVES :
if sess . get ( " include_cards " ) :
2025-10-06 09:17:59 -07:00
2025-09-09 18:15:30 -07:00
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 " )
2025-08-28 14:57:22 -07:00
try :
import base64
import json as _json
raw = _json . dumps ( payload , separators = ( " , " , " : " ) )
token = base64 . urlsafe_b64encode ( raw . encode ( " utf-8 " ) ) . decode ( " ascii " ) . rstrip ( " = " )
# Also include decoded state for convenience/testing
return JSONResponse ( { " ok " : True , " permalink " : f " /build/from?state= { token } " , " state " : payload } )
except Exception :
return JSONResponse ( { " ok " : True , " state " : payload } )
@router.get ( " /from " , response_class = HTMLResponse )
async def build_from ( request : Request , state : str | None = None ) - > HTMLResponse :
""" Load a run from a permalink token. """
sid = request . cookies . get ( " sid " ) or new_sid ( )
sess = get_session ( sid )
if state :
try :
import base64
import json as _json
pad = ' = ' * ( - len ( state ) % 4 )
raw = base64 . urlsafe_b64decode ( ( state + pad ) . encode ( " ascii " ) ) . decode ( " utf-8 " )
data = _json . loads ( raw )
sess [ " commander " ] = data . get ( " commander " )
sess [ " tags " ] = data . get ( " tags " , [ ] )
sess [ " bracket " ] = data . get ( " bracket " )
if data . get ( " ideals " ) :
sess [ " ideals " ] = data . get ( " ideals " )
sess [ " tag_mode " ] = data . get ( " tag_mode " , " AND " )
flags = data . get ( " flags " ) or { }
sess [ " use_owned_only " ] = bool ( flags . get ( " owned_only " ) )
sess [ " prefer_owned " ] = bool ( flags . get ( " prefer_owned " ) )
2025-10-02 15:31:05 -07:00
sess [ " swap_mdfc_basics " ] = bool ( flags . get ( " swap_mdfc_basics " ) )
2025-08-28 14:57:22 -07:00
sess [ " locks " ] = list ( data . get ( " locks " , [ ] ) )
2025-09-17 13:23:27 -07:00
# Optional random build rehydration
try :
r = data . get ( " random " ) or { }
if r :
2025-09-26 18:15:52 -07:00
rb_payload : dict [ str , Any ] = { }
for key in ( " seed " , " theme " , " constraints " , " primary_theme " , " secondary_theme " , " tertiary_theme " ) :
if r . get ( key ) is not None :
rb_payload [ key ] = r . get ( key )
if isinstance ( r . get ( " resolved_themes " ) , list ) :
rb_payload [ " resolved_themes " ] = list ( r . get ( " resolved_themes " ) or [ ] )
if isinstance ( r . get ( " resolved_theme_info " ) , dict ) :
rb_payload [ " resolved_theme_info " ] = dict ( r . get ( " resolved_theme_info " ) )
if r . get ( " combo_fallback " ) is not None :
rb_payload [ " combo_fallback " ] = bool ( r . get ( " combo_fallback " ) )
if r . get ( " synergy_fallback " ) is not None :
rb_payload [ " synergy_fallback " ] = bool ( r . get ( " synergy_fallback " ) )
if r . get ( " fallback_reason " ) is not None :
rb_payload [ " fallback_reason " ] = r . get ( " fallback_reason " )
if isinstance ( r . get ( " requested_themes " ) , dict ) :
requested_payload = dict ( r . get ( " requested_themes " ) )
if " auto_fill_enabled " in requested_payload :
requested_payload [ " auto_fill_enabled " ] = bool ( requested_payload . get ( " auto_fill_enabled " ) )
rb_payload [ " requested_themes " ] = requested_payload
if r . get ( " auto_fill_enabled " ) is not None :
rb_payload [ " auto_fill_enabled " ] = bool ( r . get ( " auto_fill_enabled " ) )
if r . get ( " auto_fill_applied " ) is not None :
rb_payload [ " auto_fill_applied " ] = bool ( r . get ( " auto_fill_applied " ) )
auto_filled = r . get ( " auto_filled_themes " )
if isinstance ( auto_filled , list ) :
rb_payload [ " auto_filled_themes " ] = list ( auto_filled )
display = r . get ( " display_themes " )
if isinstance ( display , list ) :
rb_payload [ " display_themes " ] = list ( display )
if " seed " in rb_payload :
try :
seed_int = int ( rb_payload [ " seed " ] )
rb_payload [ " seed " ] = seed_int
rb_payload . setdefault ( " recent_seeds " , [ seed_int ] )
except Exception :
rb_payload . setdefault ( " recent_seeds " , [ ] )
sess [ " random_build " ] = rb_payload
2025-09-17 13:23:27 -07:00
except Exception :
pass
2025-09-09 09:36:17 -07:00
# Import exclude_cards if feature is enabled and present
if ALLOW_MUST_HAVES and data . get ( " exclude_cards " ) :
sess [ " exclude_cards " ] = data . get ( " exclude_cards " )
2025-08-28 14:57:22 -07:00
sess [ " last_step " ] = 4
except Exception :
pass
locks_restored = 0
try :
locks_restored = len ( sess . get ( " locks " , [ ] ) or [ ] )
except Exception :
locks_restored = 0
2025-09-12 10:50:57 -07:00
resp = templates . TemplateResponse ( request , " build/_step4.html " , {
2025-08-28 14:57:22 -07:00
" labels " : orch . ideal_labels ( ) ,
" values " : sess . get ( " ideals " ) or orch . ideal_defaults ( ) ,
" commander " : sess . get ( " commander " ) ,
" owned_only " : bool ( sess . get ( " use_owned_only " ) ) ,
" prefer_owned " : bool ( sess . get ( " prefer_owned " ) ) ,
2025-10-02 15:31:05 -07:00
" swap_mdfc_basics " : bool ( sess . get ( " swap_mdfc_basics " ) ) ,
2025-08-28 14:57:22 -07:00
" locks_restored " : locks_restored ,
} )
resp . set_cookie ( " sid " , sid , httponly = True , samesite = " lax " )
return resp
2025-09-09 09:36:17 -07:00
@router.post ( " /validate/exclude_cards " )
async def validate_exclude_cards (
request : Request ,
exclude_cards : str = Form ( default = " " ) ,
commander : str = Form ( default = " " )
) :
2025-09-09 18:15:30 -07:00
""" 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. """
2025-09-09 09:36:17 -07:00
if not ALLOW_MUST_HAVES :
return JSONResponse ( { " error " : " Feature not enabled " } , status_code = 404 )
try :
2025-09-09 18:15:30 -07:00
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
2025-09-09 09:36:17 -07:00
2025-09-09 18:15:30 -07:00
# 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 [ ]
2025-09-09 09:36:17 -07:00
2025-09-09 18:15:30 -07:00
# Collapse duplicates
include_unique , include_dupes = collapse_duplicates ( include_list )
exclude_unique , exclude_dupes = collapse_duplicates ( exclude_list )
2025-09-09 09:36:17 -07:00
2025-09-09 18:15:30 -07:00
# Initialize result structure
2025-09-09 09:36:17 -07:00
result = {
2025-09-09 18:15:30 -07:00
" 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 " : [ ]
2025-09-09 09:36:17 -07:00
}
2025-09-09 18:15:30 -07:00
# 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 } " )
# If we have a commander, do advanced validation (color identity, etc.)
if commander and commander . strip ( ) :
try :
2025-09-09 20:18:03 -07:00
# Create a temporary builder
2025-09-09 18:15:30 -07:00
builder = DeckBuilder ( )
2025-09-09 20:18:03 -07:00
# Set up commander FIRST (before setup_dataframes)
df = builder . load_commander_data ( )
commander_rows = df [ df [ " name " ] == commander . strip ( ) ]
if not commander_rows . empty :
# Apply commander selection (this sets commander_row properly)
builder . _apply_commander_selection ( commander_rows . iloc [ 0 ] )
# Now setup dataframes (this will use the commander info)
2025-09-09 18:15:30 -07:00
builder . setup_dataframes ( )
# Get available card names for fuzzy matching
2025-09-09 20:18:03 -07:00
name_col = ' name ' if ' name ' in builder . _full_cards_df . columns else ' Name '
available_cards = set ( builder . _full_cards_df [ name_col ] . tolist ( ) )
2025-09-09 18:15:30 -07:00
# 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 )
2025-09-09 20:18:03 -07:00
# Color identity validation for includes (only if we have a valid commander with colors)
commander_colors = getattr ( builder , ' color_identity ' , [ ] )
if commander_colors :
color_validated_includes = [ ]
for card_name in result [ " includes " ] [ " legal " ] :
if builder . _validate_card_color_identity ( card_name ) :
color_validated_includes . append ( card_name )
else :
# Add color-mismatched cards to illegal instead of separate category
result [ " includes " ] [ " illegal " ] . append ( card_name )
# Update legal includes to only those that pass color identity
result [ " includes " ] [ " legal " ] = color_validated_includes
2025-09-09 18:15:30 -07:00
except Exception as validation_error :
# Advanced validation failed, but return basic validation
result [ " overall_warnings " ] . append ( f " Advanced validation unavailable: { str ( validation_error ) } " )
2025-09-09 20:18:03 -07:00
else :
# No commander provided, do basic fuzzy matching only
if fuzzy_matching and ( include_unique or exclude_unique ) :
try :
2025-09-12 10:50:57 -07:00
# Use cached available cards set (1st call populates cache)
available_cards = _available_cards ( )
2025-09-09 20:18:03 -07:00
2025-09-12 10:50:57 -07:00
# Fast path: normalized exact matches via cached sets
norm_set , norm_map = _available_cards_normalized ( )
2025-09-09 20:18:03 -07:00
# Validate includes with fuzzy matching
for card_name in include_unique :
2025-09-12 10:50:57 -07:00
from deck_builder . include_exclude_utils import normalize_punctuation
n = normalize_punctuation ( card_name )
if n in norm_set :
result [ " includes " ] [ " fuzzy_matches " ] [ card_name ] = norm_map [ n ]
result [ " includes " ] [ " legal " ] . append ( norm_map [ n ] )
continue
2025-09-09 20:18:03 -07:00
match_result = fuzzy_match_card_name ( card_name , available_cards )
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
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 :
2025-09-12 10:50:57 -07:00
from deck_builder . include_exclude_utils import normalize_punctuation
n = normalize_punctuation ( card_name )
if n in norm_set :
result [ " excludes " ] [ " fuzzy_matches " ] [ card_name ] = norm_map [ n ]
result [ " excludes " ] [ " legal " ] . append ( norm_map [ n ] )
continue
2025-09-09 20:18:03 -07:00
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 :
result [ " overall_warnings " ] . append ( f " Fuzzy matching unavailable: { str ( fuzzy_error ) } " )
2025-09-09 09:36:17 -07:00
return JSONResponse ( result )
except Exception as e :
return JSONResponse ( { " error " : str ( e ) } , status_code = 400 )