mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat: Added Partners, Backgrounds, and related variation selections to commander building.
This commit is contained in:
parent
641b305955
commit
d416c9b238
65 changed files with 11835 additions and 691 deletions
426
code/deck_builder/partner_selection.py
Normal file
426
code/deck_builder/partner_selection.py
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
"""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()
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue