2025-10-06 09:17:59 -07:00
|
|
|
"""Helpers for applying partner/background inputs to a deck build."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import ast
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from exceptions import CommanderPartnerError
|
|
|
|
|
from deck_builder.background_loader import load_background_cards
|
|
|
|
|
from deck_builder.combined_commander import (
|
|
|
|
|
CombinedCommander,
|
|
|
|
|
PartnerMode,
|
|
|
|
|
build_combined_commander,
|
|
|
|
|
)
|
|
|
|
|
from logging_util import get_logger
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
try: # Optional pandas import for type checking without heavy dependency at runtime.
|
|
|
|
|
import pandas as _pd # type: ignore
|
|
|
|
|
except Exception: # pragma: no cover - tests provide DataFrame-like objects.
|
|
|
|
|
_pd = None # type: ignore
|
|
|
|
|
|
|
|
|
|
__all__ = ["apply_partner_inputs", "normalize_lookup_name"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_lookup_name(value: str | None) -> str:
|
|
|
|
|
"""Normalize a commander/background name for case-insensitive lookups."""
|
|
|
|
|
|
|
|
|
|
return str(value or "").strip().casefold()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_partner_inputs(
|
|
|
|
|
builder: Any,
|
|
|
|
|
*,
|
|
|
|
|
primary_name: str,
|
|
|
|
|
secondary_name: str | None = None,
|
|
|
|
|
background_name: str | None = None,
|
|
|
|
|
feature_enabled: bool = False,
|
|
|
|
|
background_catalog: Any | None = None,
|
|
|
|
|
selection_source: str | None = None,
|
|
|
|
|
) -> CombinedCommander | None:
|
|
|
|
|
"""Apply partner/background inputs to a builder if the feature is enabled.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
builder: Deck builder instance exposing ``load_commander_data``.
|
|
|
|
|
primary_name: The selected primary commander name.
|
|
|
|
|
secondary_name: Optional partner/partner-with commander name.
|
|
|
|
|
background_name: Optional background name.
|
|
|
|
|
feature_enabled: Whether partner mechanics are enabled for this run.
|
|
|
|
|
background_catalog: Optional override for background catalog (testing).
|
|
|
|
|
selection_source: Optional tag describing how the selection was made (e.g., "suggestion").
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
CombinedCommander when a partner/background pairing is produced; ``None``
|
|
|
|
|
when the feature is disabled or no secondary/background inputs are given.
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
CommanderPartnerError: If inputs are invalid or commanders cannot be
|
|
|
|
|
combined under rules constraints.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
if not feature_enabled:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
secondary_name = _coerce_name(secondary_name)
|
|
|
|
|
background_name = _coerce_name(background_name)
|
|
|
|
|
|
|
|
|
|
if not primary_name:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
clean_selection_source = (selection_source or "").strip().lower() or None
|
|
|
|
|
|
|
|
|
|
if secondary_name and background_name:
|
|
|
|
|
raise CommanderPartnerError(
|
|
|
|
|
"Provide either 'secondary_commander' or 'background', not both.",
|
|
|
|
|
details={
|
|
|
|
|
"primary": primary_name,
|
|
|
|
|
"secondary_commander": secondary_name,
|
|
|
|
|
"background": background_name,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not secondary_name and not background_name:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
commander_df = builder.load_commander_data()
|
|
|
|
|
primary_row = _find_commander_row(commander_df, primary_name)
|
|
|
|
|
if primary_row is None:
|
|
|
|
|
raise CommanderPartnerError(
|
|
|
|
|
f"Primary commander not found: {primary_name}",
|
|
|
|
|
details={"commander": primary_name},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
primary_source = _row_to_commander_source(primary_row)
|
|
|
|
|
|
|
|
|
|
if background_name:
|
|
|
|
|
catalog = background_catalog or load_background_cards()
|
|
|
|
|
background_card = _lookup_background_card(catalog, background_name)
|
|
|
|
|
if background_card is None:
|
|
|
|
|
raise CommanderPartnerError(
|
|
|
|
|
f"Background not found: {background_name}",
|
|
|
|
|
details={"background": background_name},
|
|
|
|
|
)
|
|
|
|
|
combined = build_combined_commander(primary_source, background_card, PartnerMode.BACKGROUND)
|
|
|
|
|
_log_partner_selection(
|
|
|
|
|
combined,
|
|
|
|
|
primary_source=primary_source,
|
|
|
|
|
secondary_source=None,
|
|
|
|
|
background_source=background_card,
|
|
|
|
|
selection_source=clean_selection_source,
|
|
|
|
|
)
|
|
|
|
|
return combined
|
|
|
|
|
|
|
|
|
|
# Partner/Partner With flow
|
|
|
|
|
secondary_row = _find_commander_row(commander_df, secondary_name)
|
|
|
|
|
if secondary_row is None:
|
|
|
|
|
raise CommanderPartnerError(
|
|
|
|
|
f"Secondary commander not found: {secondary_name}",
|
|
|
|
|
details={"secondary_commander": secondary_name},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
secondary_source = _row_to_commander_source(secondary_row)
|
|
|
|
|
errors: list[CommanderPartnerError] = []
|
|
|
|
|
combined: CombinedCommander | None = None
|
|
|
|
|
for mode in (PartnerMode.PARTNER_WITH, PartnerMode.DOCTOR_COMPANION, PartnerMode.PARTNER):
|
|
|
|
|
try:
|
|
|
|
|
combined = build_combined_commander(primary_source, secondary_source, mode)
|
|
|
|
|
break
|
|
|
|
|
except CommanderPartnerError as exc:
|
|
|
|
|
errors.append(exc)
|
|
|
|
|
|
|
|
|
|
if combined is not None:
|
|
|
|
|
_log_partner_selection(
|
|
|
|
|
combined,
|
|
|
|
|
primary_source=primary_source,
|
|
|
|
|
secondary_source=secondary_source,
|
|
|
|
|
background_source=None,
|
|
|
|
|
selection_source=clean_selection_source,
|
|
|
|
|
)
|
|
|
|
|
return combined
|
|
|
|
|
|
|
|
|
|
if errors:
|
|
|
|
|
raise errors[-1]
|
|
|
|
|
raise CommanderPartnerError("Unable to combine commanders with provided inputs.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _coerce_name(value: str | None) -> str | None:
|
|
|
|
|
if value is None:
|
|
|
|
|
return None
|
|
|
|
|
text = str(value).strip()
|
|
|
|
|
return text or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _log_partner_selection(
|
|
|
|
|
combined: CombinedCommander,
|
|
|
|
|
*,
|
|
|
|
|
primary_source: Any,
|
|
|
|
|
secondary_source: Any | None,
|
|
|
|
|
background_source: Any | None,
|
|
|
|
|
selection_source: str | None = None,
|
|
|
|
|
) -> None:
|
|
|
|
|
mode_value = combined.partner_mode.value if isinstance(combined.partner_mode, PartnerMode) else str(combined.partner_mode)
|
|
|
|
|
secondary_role = _secondary_role_for_mode(combined.partner_mode)
|
|
|
|
|
|
|
|
|
|
combined_colors = list(combined.color_identity or ())
|
|
|
|
|
primary_colors = list(combined.primary_color_identity or _safe_colors_from_source(primary_source))
|
|
|
|
|
if secondary_source is not None:
|
|
|
|
|
secondary_colors = list(combined.secondary_color_identity or _safe_colors_from_source(secondary_source))
|
|
|
|
|
else:
|
|
|
|
|
secondary_colors = list(_safe_colors_from_source(background_source))
|
|
|
|
|
|
|
|
|
|
color_delta = {
|
|
|
|
|
"added": [color for color in combined_colors if color not in primary_colors],
|
|
|
|
|
"removed": [color for color in primary_colors if color not in combined_colors],
|
|
|
|
|
"primary": primary_colors,
|
|
|
|
|
"secondary": secondary_colors,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
primary_description = _describe_source(primary_source)
|
|
|
|
|
secondary_description = _describe_source(secondary_source)
|
|
|
|
|
background_description = _describe_source(background_source)
|
|
|
|
|
|
|
|
|
|
commanders = {
|
|
|
|
|
"primary": combined.primary_name,
|
|
|
|
|
"secondary": combined.secondary_name,
|
|
|
|
|
"background": (background_description or {}).get("display_name"),
|
|
|
|
|
}
|
|
|
|
|
sources = {
|
|
|
|
|
"primary": primary_description,
|
|
|
|
|
"secondary": secondary_description,
|
|
|
|
|
"background": background_description,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
"mode": mode_value,
|
|
|
|
|
"secondary_role": secondary_role,
|
|
|
|
|
"primary_name": commanders["primary"],
|
|
|
|
|
"secondary_name": commanders["secondary"],
|
|
|
|
|
"background_name": commanders["background"],
|
|
|
|
|
"commanders": commanders,
|
|
|
|
|
"color_identity": combined_colors,
|
|
|
|
|
"colors_after": combined_colors,
|
|
|
|
|
"colors_before": primary_colors,
|
|
|
|
|
"color_code": combined.color_code,
|
|
|
|
|
"color_label": combined.color_label,
|
|
|
|
|
"color_delta": color_delta,
|
|
|
|
|
"primary_source": sources["primary"],
|
|
|
|
|
"secondary_source": sources["secondary"],
|
|
|
|
|
"background_source": sources["background"],
|
|
|
|
|
"sources": sources,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if selection_source:
|
|
|
|
|
payload["selection_source"] = selection_source
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"partner_mode_selected",
|
|
|
|
|
extra={
|
|
|
|
|
"event": "partner_mode_selected",
|
|
|
|
|
"payload": payload,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _secondary_role_for_mode(mode: PartnerMode) -> str:
|
|
|
|
|
if mode is PartnerMode.BACKGROUND:
|
|
|
|
|
return "background"
|
|
|
|
|
if mode is PartnerMode.DOCTOR_COMPANION:
|
|
|
|
|
return "companion"
|
|
|
|
|
if mode is PartnerMode.PARTNER_WITH:
|
|
|
|
|
return "partner_with"
|
|
|
|
|
if mode is PartnerMode.PARTNER:
|
|
|
|
|
return "partner"
|
|
|
|
|
return "secondary"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_colors_from_source(source: Any | None) -> list[str]:
|
|
|
|
|
if source is None:
|
|
|
|
|
return []
|
|
|
|
|
value = getattr(source, "color_identity", None) or getattr(source, "colors", None)
|
|
|
|
|
return list(_normalize_color_identity(value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _describe_source(source: Any | None) -> dict[str, object] | None:
|
|
|
|
|
if source is None:
|
|
|
|
|
return None
|
|
|
|
|
name = getattr(source, "name", None) or getattr(source, "display_name", None)
|
|
|
|
|
display_name = getattr(source, "display_name", None) or name
|
|
|
|
|
partner_with = getattr(source, "partner_with", None)
|
|
|
|
|
if partner_with is None:
|
|
|
|
|
partner_with = getattr(source, "partnerWith", None)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"name": name,
|
|
|
|
|
"display_name": display_name,
|
|
|
|
|
"color_identity": _safe_colors_from_source(source),
|
|
|
|
|
"themes": list(getattr(source, "themes", ()) or getattr(source, "theme_tags", ()) or []),
|
|
|
|
|
"partner_with": list(partner_with or ()),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _find_commander_row(df: Any, name: str | None):
|
|
|
|
|
if name is None:
|
|
|
|
|
return None
|
|
|
|
|
target = normalize_lookup_name(name)
|
|
|
|
|
if not target:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
if _pd is not None and isinstance(df, _pd.DataFrame): # type: ignore
|
|
|
|
|
columns = [col for col in ("name", "faceName") if col in df.columns]
|
|
|
|
|
for col in columns:
|
|
|
|
|
series = df[col].astype(str).str.casefold()
|
|
|
|
|
matches = df[series == target]
|
|
|
|
|
if not matches.empty:
|
|
|
|
|
return matches.iloc[0]
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Fallback for DataFrame-like sequences
|
|
|
|
|
for row in getattr(df, "itertuples", lambda index=False: [])(): # pragma: no cover - defensive
|
|
|
|
|
for attr in ("name", "faceName"):
|
|
|
|
|
value = getattr(row, attr, None)
|
|
|
|
|
if normalize_lookup_name(value) == target:
|
|
|
|
|
return getattr(df, "loc", lambda *_: row)(row.Index) if hasattr(row, "Index") else row
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _row_to_commander_source(row: Any) -> SimpleNamespace:
|
|
|
|
|
themes = _normalize_string_sequence(row.get("themeTags"))
|
|
|
|
|
partner_with = _normalize_string_sequence(
|
|
|
|
|
row.get("partnerWith")
|
|
|
|
|
or row.get("partner_with")
|
|
|
|
|
or row.get("partnerNames")
|
|
|
|
|
or row.get("partner_names")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
name=_safe_str(row.get("name")),
|
|
|
|
|
display_name=_safe_str(row.get("faceName")) or _safe_str(row.get("name")),
|
|
|
|
|
color_identity=_normalize_color_identity(row.get("colorIdentity")),
|
|
|
|
|
colors=_normalize_color_identity(row.get("colors")),
|
|
|
|
|
themes=themes,
|
|
|
|
|
theme_tags=themes,
|
|
|
|
|
raw_tags=themes,
|
|
|
|
|
partner_with=partner_with,
|
|
|
|
|
oracle_text=_safe_str(row.get("text") or row.get("oracleText")),
|
|
|
|
|
type_line=_safe_str(row.get("type") or row.get("type_line")),
|
|
|
|
|
supports_backgrounds=_normalize_bool(row.get("supportsBackgrounds") or row.get("supports_backgrounds")),
|
|
|
|
|
is_partner=_normalize_bool(row.get("isPartner") or row.get("is_partner")),
|
|
|
|
|
is_background=_normalize_bool(row.get("isBackground") or row.get("is_background")),
|
|
|
|
|
is_doctor=_normalize_bool(row.get("isDoctor") or row.get("is_doctor")),
|
|
|
|
|
is_doctors_companion=_normalize_bool(row.get("isDoctorsCompanion") or row.get("is_doctors_companion")),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lookup_background_card(catalog: Any, name: str) -> Any | None:
|
|
|
|
|
lowered = normalize_lookup_name(name)
|
|
|
|
|
|
|
|
|
|
getter = getattr(catalog, "get", None)
|
|
|
|
|
if callable(getter):
|
|
|
|
|
result = getter(name)
|
|
|
|
|
if result is None:
|
|
|
|
|
result = getter(lowered)
|
|
|
|
|
if result is not None:
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
entries = getattr(catalog, "entries", None)
|
|
|
|
|
if entries is not None:
|
|
|
|
|
for entry in entries:
|
|
|
|
|
display = normalize_lookup_name(getattr(entry, "display_name", None))
|
|
|
|
|
if display == lowered:
|
|
|
|
|
return entry
|
|
|
|
|
raw = normalize_lookup_name(getattr(entry, "name", None))
|
|
|
|
|
if raw == lowered:
|
|
|
|
|
return entry
|
|
|
|
|
slug = normalize_lookup_name(getattr(entry, "slug", None))
|
|
|
|
|
if slug == lowered:
|
|
|
|
|
return entry
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_color_identity(value: Any) -> tuple[str, ...]:
|
|
|
|
|
tokens = _normalize_string_sequence(value)
|
|
|
|
|
result: list[str] = []
|
|
|
|
|
for token in tokens:
|
|
|
|
|
if len(token) > 1 and "," not in token and " " not in token:
|
|
|
|
|
if all(ch in "WUBRGC" for ch in token):
|
|
|
|
|
result.extend(ch for ch in token)
|
|
|
|
|
else:
|
|
|
|
|
result.append(token)
|
|
|
|
|
else:
|
|
|
|
|
result.append(token)
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
ordered: list[str] = []
|
|
|
|
|
for item in result:
|
|
|
|
|
if item not in seen:
|
|
|
|
|
seen.add(item)
|
|
|
|
|
ordered.append(item)
|
|
|
|
|
return tuple(ordered)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_string_sequence(value: Any) -> tuple[str, ...]:
|
|
|
|
|
if value is None:
|
|
|
|
|
return tuple()
|
2025-10-28 08:21:52 -07:00
|
|
|
# Handle numpy arrays, lists, tuples, sets, and other sequences
|
|
|
|
|
try:
|
|
|
|
|
import numpy as np
|
|
|
|
|
is_numpy = isinstance(value, np.ndarray)
|
|
|
|
|
except ImportError:
|
|
|
|
|
is_numpy = False
|
|
|
|
|
|
|
|
|
|
if isinstance(value, (list, tuple, set)) or is_numpy:
|
2025-10-06 09:17:59 -07:00
|
|
|
items = list(value)
|
|
|
|
|
else:
|
|
|
|
|
text = _safe_str(value)
|
|
|
|
|
if not text:
|
|
|
|
|
return tuple()
|
|
|
|
|
try:
|
|
|
|
|
parsed = ast.literal_eval(text)
|
|
|
|
|
except Exception: # pragma: no cover - non literal values handled below
|
|
|
|
|
parsed = None
|
|
|
|
|
if isinstance(parsed, (list, tuple, set)):
|
|
|
|
|
items = list(parsed)
|
|
|
|
|
elif ";" in text:
|
|
|
|
|
items = [part.strip() for part in text.split(";")]
|
|
|
|
|
elif "," in text:
|
|
|
|
|
items = [part.strip() for part in text.split(",")]
|
|
|
|
|
else:
|
|
|
|
|
items = [text]
|
|
|
|
|
collected: list[str] = []
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
for item in items:
|
|
|
|
|
token = _safe_str(item)
|
|
|
|
|
if not token:
|
|
|
|
|
continue
|
|
|
|
|
key = token.casefold()
|
|
|
|
|
if key in seen:
|
|
|
|
|
continue
|
|
|
|
|
seen.add(key)
|
|
|
|
|
collected.append(token)
|
|
|
|
|
return tuple(collected)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_bool(value: Any) -> bool:
|
|
|
|
|
if isinstance(value, bool):
|
|
|
|
|
return value
|
|
|
|
|
if value in (0, 1):
|
|
|
|
|
return bool(value)
|
|
|
|
|
text = _safe_str(value).casefold()
|
|
|
|
|
if not text:
|
|
|
|
|
return False
|
|
|
|
|
if text in {"1", "true", "t", "yes", "on"}:
|
|
|
|
|
return True
|
|
|
|
|
if text in {"0", "false", "f", "no", "off"}:
|
|
|
|
|
return False
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_str(value: Any) -> str:
|
|
|
|
|
if value is None:
|
|
|
|
|
return ""
|
|
|
|
|
if isinstance(value, float) and value != value: # NaN check
|
|
|
|
|
return ""
|
|
|
|
|
text = str(value)
|
|
|
|
|
if "\\r\\n" in text or "\\n" in text or "\\r" in text:
|
|
|
|
|
text = (
|
|
|
|
|
text.replace("\\r\\n", "\n")
|
|
|
|
|
.replace("\\r", "\n")
|
|
|
|
|
.replace("\\n", "\n")
|
|
|
|
|
)
|
|
|
|
|
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
|
|
|
return text.strip()
|