mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
325 lines
13 KiB
Python
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",
|
|
]
|