mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-19 03:36:30 +01:00
refactor: modular route organization (Phase 1-2 complete)
- Split monolithic build route handler into focused modules - Extract validation, multi-copy, include/exclude, themes, and partner routes - Add response utilities and telemetry decorators - Create route pattern documentation - Fix multi-copy detection bug (tag key mismatch) - Improve code maintainability and testability Roadmap 9 M1 Phase 1-2
This commit is contained in:
parent
97da117ccb
commit
e81b47bccf
20 changed files with 2852 additions and 1552 deletions
738
code/web/routes/build_partners.py
Normal file
738
code/web/routes/build_partners.py
Normal file
|
|
@ -0,0 +1,738 @@
|
|||
"""
|
||||
Partner mechanics routes and utilities for deck building.
|
||||
|
||||
Handles partner commanders, backgrounds, Doctor/Companion pairings,
|
||||
and partner preview/validation functionality.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
from urllib.parse import quote_plus
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..app import (
|
||||
ENABLE_PARTNER_MECHANICS,
|
||||
ENABLE_PARTNER_SUGGESTIONS,
|
||||
)
|
||||
from ..services.telemetry import log_partner_suggestion_selected
|
||||
from ..services.partner_suggestions import get_partner_suggestions
|
||||
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 deck_builder.builder import DeckBuilder
|
||||
from exceptions import CommanderPartnerError
|
||||
from code.logging_util import get_logger
|
||||
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
_PARTNER_MODE_LABELS = {
|
||||
"partner": "Partner",
|
||||
"partner_restricted": "Partner (Restricted)",
|
||||
"partner_with": "Partner With",
|
||||
"background": "Choose a Background",
|
||||
"doctor_companion": "Doctor & Companion",
|
||||
}
|
||||
|
||||
|
||||
_WUBRG_ORDER = ["W", "U", "B", "R", "G"]
|
||||
_COLOR_NAME_MAP = {
|
||||
"W": "White",
|
||||
"U": "Blue",
|
||||
"B": "Black",
|
||||
"R": "Red",
|
||||
"G": "Green",
|
||||
}
|
||||
|
||||
|
||||
def _color_code(identity: Iterable[str]) -> str:
|
||||
"""Convert color identity to standard WUBRG-ordered code."""
|
||||
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:
|
||||
"""Format color identity as human-readable label with code."""
|
||||
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:
|
||||
"""Convert partner mode to display label."""
|
||||
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:
|
||||
"""Generate Scryfall image URL for card."""
|
||||
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:
|
||||
"""Generate Scryfall search URL for card."""
|
||||
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:
|
||||
"""Determine the role label for the secondary commander based on pairing mode."""
|
||||
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]:
|
||||
"""Convert CombinedCommander object to JSON-serializable payload."""
|
||||
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]:
|
||||
"""
|
||||
Build list of valid partner options for a given primary commander.
|
||||
|
||||
Returns:
|
||||
Tuple of (partner_options_list, variant_type) where variant is
|
||||
"partner", "doctor_companion", or 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]]:
|
||||
"""Build list of available background cards for Choose a Background commanders."""
|
||||
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]]:
|
||||
"""Fallback: load backgrounds from commander catalog when background_cards.json is unavailable."""
|
||||
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", ()):
|
||||
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]:
|
||||
"""
|
||||
Build complete partner UI context for rendering partner selection components.
|
||||
|
||||
This includes partner options, background options, preview payload,
|
||||
suggestions, warnings, and all necessary state for the partner UI.
|
||||
"""
|
||||
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
|
||||
|
||||
# Auto-prefill Partner With targets
|
||||
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))
|
||||
|
||||
# Dynamic labels based on variant
|
||||
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."
|
||||
|
||||
# Partner suggestions
|
||||
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)
|
||||
|
||||
# Generate preview if not provided
|
||||
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,
|
||||
]:
|
||||
"""
|
||||
Resolve and validate partner selection, applying auto-pairing when appropriate.
|
||||
|
||||
Returns:
|
||||
Tuple of (error, preview_payload, warnings, auto_note, resolved_secondary,
|
||||
resolved_background, partner_mode, auto_assigned_flag)
|
||||
"""
|
||||
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
|
||||
|
||||
# Auto-assign Partner With targets
|
||||
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
|
||||
|
||||
|
||||
@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:
|
||||
"""
|
||||
Preview a partner pairing and return combined commander details.
|
||||
|
||||
This endpoint validates partner selections and returns:
|
||||
- Combined color identity and theme tags
|
||||
- Partner preview payload with images and metadata
|
||||
- Warnings about legality or capability mismatches
|
||||
- Auto-pairing information for Partner With targets
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
commander: Primary commander name
|
||||
partner_enabled: Whether partner mechanics are enabled ("1"/"true"/etc.)
|
||||
secondary_commander: Secondary partner commander name
|
||||
background: Background card name (for Choose a Background commanders)
|
||||
partner_auto_opt_out: Opt-out of auto-pairing for Partner With
|
||||
scope: Request scope identifier
|
||||
selection_source: Source of selection (e.g., "suggestion", "manual")
|
||||
|
||||
Returns:
|
||||
JSONResponse with partner preview data and validation results
|
||||
"""
|
||||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue