mtg_python_deckbuilder/code/deck_builder/combined_commander.py

325 lines
13 KiB
Python

"""Combine commander selections across partner/background modes."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Iterable, Sequence, Tuple
from exceptions import CommanderPartnerError
from code.deck_builder.partner_background_utils import analyze_partner_background
from code.deck_builder.color_identity_utils import canon_color_code, color_label_from_code
_WUBRG_ORDER: Tuple[str, ...] = ("W", "U", "B", "R", "G", "C")
_COLOR_PRIORITY = {color: index for index, color in enumerate(_WUBRG_ORDER)}
class PartnerMode(str, Enum):
"""Enumerates supported partner mechanics."""
NONE = "none"
PARTNER = "partner"
PARTNER_WITH = "partner_with"
BACKGROUND = "background"
DOCTOR_COMPANION = "doctor_companion"
@dataclass(frozen=True, slots=True)
class CombinedCommander:
"""Represents merged commander metadata for deck building."""
primary_name: str
secondary_name: str | None
partner_mode: PartnerMode
color_identity: Tuple[str, ...]
theme_tags: Tuple[str, ...]
raw_tags_primary: Tuple[str, ...]
raw_tags_secondary: Tuple[str, ...]
warnings: Tuple[str, ...]
color_code: str = ""
color_label: str = ""
primary_color_identity: Tuple[str, ...] = ()
secondary_color_identity: Tuple[str, ...] = ()
@dataclass(frozen=True)
class _CommanderData:
name: str
display_name: str
color_identity: Tuple[str, ...]
themes: Tuple[str, ...]
raw_tags: Tuple[str, ...]
partner_with: Tuple[str, ...]
is_partner: bool
supports_backgrounds: bool
is_background: bool
is_doctor: bool
is_doctors_companion: bool
@classmethod
def from_source(cls, source: object) -> "_CommanderData":
name = _get_attr(source, "name") or _get_attr(source, "display_name") or ""
display_name = _get_attr(source, "display_name") or name
if not display_name:
raise CommanderPartnerError("Commander is missing a display name", details={"source": repr(source)})
color_identity = _normalize_colors(_get_attr(source, "color_identity") or _get_attr(source, "colorIdentity"))
themes = _normalize_theme_tags(
_get_attr(source, "themes")
or _get_attr(source, "theme_tags")
or _get_attr(source, "themeTags")
)
raw_tags = tuple(_ensure_sequence(_get_attr(source, "themes") or _get_attr(source, "theme_tags") or _get_attr(source, "themeTags")))
partner_with: Tuple[str, ...] = tuple(_ensure_sequence(_get_attr(source, "partner_with") or ()))
oracle_text = _get_attr(source, "oracle_text") or _get_attr(source, "text")
type_line = _get_attr(source, "type_line") or _get_attr(source, "type")
detection = analyze_partner_background(type_line, oracle_text, raw_tags or themes)
if not partner_with:
partner_with = detection.partner_with
is_partner = bool(_get_attr(source, "is_partner")) or detection.has_partner
supports_backgrounds = bool(_get_attr(source, "supports_backgrounds")) or detection.choose_background
is_background = bool(_get_attr(source, "is_background")) or detection.is_background
is_doctor = bool(_get_attr(source, "is_doctor")) or detection.is_doctor
is_doctors_companion = bool(_get_attr(source, "is_doctors_companion")) or detection.is_doctors_companion
return cls(
name=name,
display_name=display_name,
color_identity=color_identity,
themes=themes,
raw_tags=tuple(raw_tags),
partner_with=partner_with,
is_partner=is_partner,
supports_backgrounds=supports_backgrounds,
is_background=is_background,
is_doctor=is_doctor,
is_doctors_companion=is_doctors_companion,
)
def build_combined_commander(
primary: object,
secondary: object | None,
mode: PartnerMode,
) -> CombinedCommander:
"""Merge commander metadata according to the selected partner mode."""
primary_data = _CommanderData.from_source(primary)
secondary_data = _CommanderData.from_source(secondary) if secondary is not None else None
_validate_mode_inputs(primary_data, secondary_data, mode)
warnings = _collect_warnings(primary_data, secondary_data)
color_identity = _merge_colors(primary_data, secondary_data)
theme_tags = _merge_theme_tags(primary_data.themes, secondary_data.themes if secondary_data else ())
raw_secondary = secondary_data.raw_tags if secondary_data else tuple()
secondary_name = secondary_data.display_name if secondary_data else None
color_code = canon_color_code(color_identity)
color_label = color_label_from_code(color_code)
primary_colors = primary_data.color_identity
secondary_colors = secondary_data.color_identity if secondary_data else tuple()
return CombinedCommander(
primary_name=primary_data.display_name,
secondary_name=secondary_name,
partner_mode=mode,
color_identity=color_identity,
theme_tags=theme_tags,
raw_tags_primary=primary_data.raw_tags,
raw_tags_secondary=raw_secondary,
warnings=warnings,
color_code=color_code,
color_label=color_label,
primary_color_identity=primary_colors,
secondary_color_identity=secondary_colors,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_mode_inputs(
primary: _CommanderData,
secondary: _CommanderData | None,
mode: PartnerMode,
) -> None:
details = {
"mode": mode.value,
"primary": primary.display_name,
"secondary": secondary.display_name if secondary else None,
}
if mode is PartnerMode.NONE:
if secondary is not None:
raise CommanderPartnerError("Secondary commander provided but partner mode is NONE", details=details)
return
if secondary is None:
raise CommanderPartnerError("Secondary commander is required for selected partner mode", details=details)
_ensure_distinct(primary, secondary, details)
if mode is PartnerMode.PARTNER:
if not primary.is_partner:
raise CommanderPartnerError(f"{primary.display_name} does not have Partner", details=details)
if not secondary.is_partner:
raise CommanderPartnerError(f"{secondary.display_name} does not have Partner", details=details)
if secondary.is_background:
raise CommanderPartnerError("Selected secondary is a Background; choose partner mode BACKGROUND", details=details)
return
if mode is PartnerMode.PARTNER_WITH:
_validate_partner_with(primary, secondary, details)
return
if mode is PartnerMode.BACKGROUND:
_validate_background(primary, secondary, details)
return
if mode is PartnerMode.DOCTOR_COMPANION:
_validate_doctor_companion(primary, secondary, details)
return
raise CommanderPartnerError("Unsupported partner mode", details=details)
def _ensure_distinct(primary: _CommanderData, secondary: _CommanderData, details: dict[str, object]) -> None:
if primary.display_name.casefold() == secondary.display_name.casefold():
raise CommanderPartnerError("Primary and secondary commanders must be different", details=details)
def _validate_partner_with(primary: _CommanderData, secondary: _CommanderData, details: dict[str, object]) -> None:
if secondary.is_background:
raise CommanderPartnerError("Background cannot be used in PARTNER_WITH mode", details=details)
if not primary.partner_with:
raise CommanderPartnerError(f"{primary.display_name} does not specify a Partner With target", details=details)
if not secondary.partner_with:
raise CommanderPartnerError(f"{secondary.display_name} does not specify a Partner With target", details=details)
secondary_names = {_standardize_name(name) for name in secondary.partner_with}
primary_names = {_standardize_name(name) for name in primary.partner_with}
if _standardize_name(secondary.display_name) not in primary_names:
raise CommanderPartnerError(
f"{secondary.display_name} is not a legal Partner With target for {primary.display_name}",
details=details,
)
if _standardize_name(primary.display_name) not in secondary_names:
raise CommanderPartnerError(
f"{primary.display_name} is not a legal Partner With target for {secondary.display_name}",
details=details,
)
def _validate_background(primary: _CommanderData, secondary: _CommanderData, details: dict[str, object]) -> None:
if not secondary.is_background:
raise CommanderPartnerError("Selected secondary commander is not a Background", details=details)
if not primary.supports_backgrounds:
raise CommanderPartnerError(f"{primary.display_name} cannot choose a Background", details=details)
if primary.is_background:
raise CommanderPartnerError("Background cannot be used as primary commander", details=details)
def _validate_doctor_companion(primary: _CommanderData, secondary: _CommanderData, details: dict[str, object]) -> None:
primary_is_doctor = bool(primary.is_doctor)
primary_is_companion = bool(primary.is_doctors_companion)
secondary_is_doctor = bool(secondary.is_doctor)
secondary_is_companion = bool(secondary.is_doctors_companion)
if not (primary_is_doctor or primary_is_companion):
raise CommanderPartnerError(f"{primary.display_name} is not a Doctor or Doctor's Companion", details=details)
if not (secondary_is_doctor or secondary_is_companion):
raise CommanderPartnerError(f"{secondary.display_name} is not a Doctor or Doctor's Companion", details=details)
if primary_is_doctor and secondary_is_doctor:
raise CommanderPartnerError("Doctor commanders must pair with a Doctor's Companion", details=details)
if primary_is_companion and secondary_is_companion:
raise CommanderPartnerError("Doctor's Companion must pair with a Doctor", details=details)
# Ensure pairing is complementary doctor <-> companion
if primary_is_doctor and not secondary_is_companion:
raise CommanderPartnerError(f"{secondary.display_name} is not a legal Doctor's Companion", details=details)
if primary_is_companion and not secondary_is_doctor:
raise CommanderPartnerError(f"{secondary.display_name} is not a legal Doctor pairing", details=details)
def _collect_warnings(
primary: _CommanderData,
secondary: _CommanderData | None,
) -> Tuple[str, ...]:
warnings: list[str] = []
if primary.is_partner and primary.supports_backgrounds:
warnings.append(
f"{primary.display_name} has both Partner and Background abilities; ensure the selected mode is intentional."
)
if secondary and secondary.is_partner and secondary.supports_backgrounds:
warnings.append(
f"{secondary.display_name} has both Partner and Background abilities; ensure the selected mode is intentional."
)
return tuple(warnings)
def _merge_colors(primary: _CommanderData, secondary: _CommanderData | None) -> Tuple[str, ...]:
colors = set(primary.color_identity)
if secondary:
colors.update(secondary.color_identity)
if not colors:
return tuple()
return tuple(sorted(colors, key=lambda color: (_COLOR_PRIORITY.get(color, len(_COLOR_PRIORITY)), color)))
def _merge_theme_tags(*sources: Iterable[str]) -> Tuple[str, ...]:
seen: set[str] = set()
merged: list[str] = []
for source in sources:
for tag in source:
clean = tag.strip()
if not clean:
continue
key = clean.casefold()
if key in seen:
continue
seen.add(key)
merged.append(clean)
return tuple(merged)
def _normalize_colors(colors: Sequence[str] | None) -> Tuple[str, ...]:
if not colors:
return tuple()
normalized = [str(color).strip().upper() for color in colors]
normalized = [color for color in normalized if color]
return tuple(normalized)
def _normalize_theme_tags(tags: Sequence[str] | None) -> Tuple[str, ...]:
if not tags:
return tuple()
return tuple(str(tag).strip() for tag in tags if str(tag).strip())
def _ensure_sequence(value: object) -> Sequence[str]:
if value is None:
return ()
if isinstance(value, (list, tuple)):
return value
return (value,)
def _standardize_name(name: str) -> str:
return name.strip().casefold()
def _get_attr(source: object, attr: str) -> object:
return getattr(source, attr, None)
__all__ = [
"CombinedCommander",
"PartnerMode",
"build_combined_commander",
]