mtg_python_deckbuilder/code/deck_builder/partner_selection.py

433 lines
15 KiB
Python

"""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()
# 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:
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()