feat: Added Partners, Backgrounds, and related variation selections to commander building.

This commit is contained in:
matt 2025-10-06 09:17:59 -07:00
parent 641b305955
commit d416c9b238
65 changed files with 11835 additions and 691 deletions

View file

@ -0,0 +1,262 @@
"""Loader for background cards derived from `background_cards.csv`."""
from __future__ import annotations
import ast
import csv
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
import re
from typing import Mapping, Tuple
from code.logging_util import get_logger
from deck_builder.partner_background_utils import analyze_partner_background
from path_util import csv_dir
LOGGER = get_logger(__name__)
BACKGROUND_FILENAME = "background_cards.csv"
@dataclass(frozen=True, slots=True)
class BackgroundCard:
"""Normalized background card entry."""
name: str
face_name: str | None
display_name: str
slug: str
color_identity: Tuple[str, ...]
colors: Tuple[str, ...]
mana_cost: str
mana_value: float | None
type_line: str
oracle_text: str
keywords: Tuple[str, ...]
theme_tags: Tuple[str, ...]
raw_theme_tags: Tuple[str, ...]
edhrec_rank: int | None
layout: str
side: str | None
@dataclass(frozen=True, slots=True)
class BackgroundCatalog:
source_path: Path
etag: str
mtime_ns: int
size: int
version: str
entries: Tuple[BackgroundCard, ...]
by_name: Mapping[str, BackgroundCard]
def get(self, name: str) -> BackgroundCard | None:
return self.by_name.get(name.lower())
def load_background_cards(
source_path: str | Path | None = None,
) -> BackgroundCatalog:
"""Load and cache background card data."""
resolved = _resolve_background_path(source_path)
try:
stat = resolved.stat()
mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))
size = stat.st_size
except FileNotFoundError:
raise FileNotFoundError(f"Background CSV not found at {resolved}") from None
entries, version = _load_background_cards_cached(str(resolved), mtime_ns)
etag = f"{size}-{mtime_ns}-{len(entries)}"
catalog = BackgroundCatalog(
source_path=resolved,
etag=etag,
mtime_ns=mtime_ns,
size=size,
version=version,
entries=entries,
by_name={card.display_name.lower(): card for card in entries},
)
LOGGER.info("background_cards_loaded count=%s version=%s path=%s", len(entries), version, resolved)
return catalog
@lru_cache(maxsize=4)
def _load_background_cards_cached(path_str: str, mtime_ns: int) -> Tuple[Tuple[BackgroundCard, ...], str]:
path = Path(path_str)
if not path.exists():
return tuple(), "unknown"
with path.open("r", encoding="utf-8", newline="") as handle:
first_line = handle.readline()
version = "unknown"
if first_line.startswith("#"):
version = _parse_version(first_line)
else:
handle.seek(0)
reader = csv.DictReader(handle)
if reader.fieldnames is None:
return tuple(), version
entries = _rows_to_cards(reader)
frozen = tuple(entries)
return frozen, version
def _resolve_background_path(override: str | Path | None) -> Path:
if override:
return Path(override).resolve()
return (Path(csv_dir()) / BACKGROUND_FILENAME).resolve()
def _parse_version(line: str) -> str:
tokens = line.lstrip("# ").strip().split()
for token in tokens:
if "=" not in token:
continue
key, value = token.split("=", 1)
if key == "version":
return value
return "unknown"
def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]:
entries: list[BackgroundCard] = []
seen: set[str] = set()
for raw in reader:
if not raw:
continue
card = _row_to_card(raw)
if card is None:
continue
key = card.display_name.lower()
if key in seen:
continue
seen.add(key)
entries.append(card)
entries.sort(key=lambda card: card.display_name)
return entries
def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None:
name = _clean_str(row.get("name"))
face_name = _clean_str(row.get("faceName")) or None
display = face_name or name
if not display:
return None
type_line = _clean_str(row.get("type"))
oracle_text = _clean_multiline(row.get("text"))
raw_theme_tags = tuple(_parse_literal_list(row.get("themeTags")))
detection = analyze_partner_background(type_line, oracle_text, raw_theme_tags)
if not detection.is_background:
return None
return BackgroundCard(
name=name,
face_name=face_name,
display_name=display,
slug=_slugify(display),
color_identity=_parse_color_list(row.get("colorIdentity")),
colors=_parse_color_list(row.get("colors")),
mana_cost=_clean_str(row.get("manaCost")),
mana_value=_parse_float(row.get("manaValue")),
type_line=type_line,
oracle_text=oracle_text,
keywords=tuple(_split_list(row.get("keywords"))),
theme_tags=tuple(tag for tag in raw_theme_tags if tag),
raw_theme_tags=raw_theme_tags,
edhrec_rank=_parse_int(row.get("edhrecRank")),
layout=_clean_str(row.get("layout")) or "normal",
side=_clean_str(row.get("side")) or None,
)
def _clean_str(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def _clean_multiline(value: object) -> str:
if value is None:
return ""
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
return "\n".join(line.rstrip() for line in text.splitlines())
def _parse_literal_list(value: object) -> list[str]:
if value is None:
return []
if isinstance(value, (list, tuple, set)):
return [str(item).strip() for item in value if str(item).strip()]
text = str(value).strip()
if not text:
return []
try:
parsed = ast.literal_eval(text)
except Exception:
parsed = None
if isinstance(parsed, (list, tuple, set)):
return [str(item).strip() for item in parsed if str(item).strip()]
parts = [part.strip() for part in text.replace(";", ",").split(",")]
return [part for part in parts if part]
def _split_list(value: object) -> list[str]:
text = _clean_str(value)
if not text:
return []
parts = [part.strip() for part in text.split(",")]
return [part for part in parts if part]
def _parse_color_list(value: object) -> Tuple[str, ...]:
text = _clean_str(value)
if not text:
return tuple()
parts = [part.strip().upper() for part in text.split(",")]
return tuple(part for part in parts if part)
def _parse_float(value: object) -> float | None:
text = _clean_str(value)
if not text:
return None
try:
return float(text)
except ValueError:
return None
def _parse_int(value: object) -> int | None:
text = _clean_str(value)
if not text:
return None
try:
return int(float(text))
except ValueError:
return None
def _slugify(value: str) -> str:
lowered = value.strip().lower()
allowed = [ch if ch.isalnum() else "-" for ch in lowered]
slug = "".join(allowed)
slug = re.sub(r"-+", "-", slug)
return slug.strip("-")
def clear_background_cards_cache() -> None:
"""Clear the memoized background card cache (testing/support)."""
_load_background_cards_cached.cache_clear()
__all__ = [
"BackgroundCard",
"BackgroundCatalog",
"clear_background_cards_cache",
"load_background_cards",
]

View file

@ -1054,26 +1054,40 @@ class DeckBuilder(
if self.commander_row is None:
raise RuntimeError("Commander must be selected before determining color identity.")
raw_ci = self.commander_row.get('colorIdentity')
if isinstance(raw_ci, list):
colors_list = raw_ci
elif isinstance(raw_ci, str) and raw_ci.strip():
# Could be formatted like "['B','G']" or 'BG'; attempt simple parsing
if ',' in raw_ci:
colors_list = [c.strip().strip("'[] ") for c in raw_ci.split(',') if c.strip().strip("'[] ")]
else:
colors_list = [c for c in raw_ci if c.isalpha()]
override_identity = getattr(self, 'combined_color_identity', None)
colors_list: List[str]
if override_identity:
colors_list = [str(c).strip().upper() for c in override_identity if str(c).strip()]
else:
# Fallback to 'colors' field or treat as colorless
alt = self.commander_row.get('colors')
if isinstance(alt, list):
colors_list = alt
elif isinstance(alt, str) and alt.strip():
colors_list = [c for c in alt if c.isalpha()]
raw_ci = self.commander_row.get('colorIdentity')
if isinstance(raw_ci, list):
colors_list = [str(c).strip().upper() for c in raw_ci]
elif isinstance(raw_ci, str) and raw_ci.strip():
# Could be formatted like "['B','G']" or 'BG'; attempt simple parsing
if ',' in raw_ci:
colors_list = [c.strip().strip("'[] ").upper() for c in raw_ci.split(',') if c.strip().strip("'[] ")]
else:
colors_list = [c.upper() for c in raw_ci if c.isalpha()]
else:
colors_list = []
# Fallback to 'colors' field or treat as colorless
alt = self.commander_row.get('colors')
if isinstance(alt, list):
colors_list = [str(c).strip().upper() for c in alt]
elif isinstance(alt, str) and alt.strip():
colors_list = [c.upper() for c in alt if c.isalpha()]
else:
colors_list = []
self.color_identity = [c.upper() for c in colors_list]
deduped: List[str] = []
seen_tokens: set[str] = set()
for token in colors_list:
if not token:
continue
if token not in seen_tokens:
seen_tokens.add(token)
deduped.append(token)
self.color_identity = deduped
self.color_identity_key = self._canonical_color_key(self.color_identity)
# Match against maps
@ -1097,6 +1111,14 @@ class DeckBuilder(
self.color_identity_full = full
self.files_to_load = load_files
# Synchronize commander summary metadata when partner overrides are present
if override_identity and self.commander_dict:
try:
self.commander_dict["Color Identity"] = list(self.color_identity)
self.commander_dict["Colors"] = list(self.color_identity)
except Exception:
pass
return full, load_files
def setup_dataframes(self) -> pd.DataFrame:

View file

@ -512,7 +512,7 @@ DEFAULT_THEME_TAGS = [
'Enter the Battlefield', 'Equipment', 'Exile Matters', 'Infect',
'Interaction', 'Lands Matter', 'Leave the Battlefield', 'Legends Matter',
'Life Matters', 'Mill', 'Monarch', 'Protection', 'Ramp', 'Reanimate',
'Removal', 'Sacrifice Matters', 'Spellslinger', 'Stax', 'Super Friends',
'Removal', 'Sacrifice Matters', 'Spellslinger', 'Stax', 'Superfriends',
'Theft', 'Token Creation', 'Tokens Matter', 'Voltron', 'X Spells'
]

View file

@ -0,0 +1,134 @@
"""Utilities for working with Magic color identity tuples and labels."""
from __future__ import annotations
from typing import Iterable, List
__all__ = [
"canon_color_code",
"format_color_label",
"color_label_from_code",
"normalize_colors",
]
_WUBRG_ORDER: tuple[str, ...] = ("W", "U", "B", "R", "G")
_VALID_COLORS: frozenset[str] = frozenset((*_WUBRG_ORDER, "C"))
_COLOR_NAMES: dict[str, str] = {
"W": "White",
"U": "Blue",
"B": "Black",
"R": "Red",
"G": "Green",
"C": "Colorless",
}
_TWO_COLOR_LABELS: dict[str, str] = {
"WU": "Azorius",
"UB": "Dimir",
"BR": "Rakdos",
"RG": "Gruul",
"WG": "Selesnya",
"WB": "Orzhov",
"UR": "Izzet",
"BG": "Golgari",
"WR": "Boros",
"UG": "Simic",
}
_THREE_COLOR_LABELS: dict[str, str] = {
"WUB": "Esper",
"UBR": "Grixis",
"BRG": "Jund",
"WRG": "Naya",
"WUG": "Bant",
"WBR": "Mardu",
"WUR": "Jeskai",
"UBG": "Sultai",
"URG": "Temur",
"WBG": "Abzan",
}
_FOUR_COLOR_LABELS: dict[str, str] = {
"WUBR": "Yore-Tiller",
"WUBG": "Witch-Maw",
"WURG": "Ink-Treader",
"WBRG": "Dune-Brood",
"UBRG": "Glint-Eye",
}
def _extract_tokens(identity: Iterable[str] | str | None) -> List[str]:
if identity is None:
return []
tokens: list[str] = []
if isinstance(identity, str):
identity_iter: Iterable[str] = (identity,)
else:
identity_iter = identity
for item in identity_iter:
if item is None:
continue
text = str(item).strip().upper()
if not text:
continue
if len(text) > 1 and text.isalpha():
for ch in text:
if ch in _VALID_COLORS:
tokens.append(ch)
else:
for ch in text:
if ch in _VALID_COLORS:
tokens.append(ch)
return tokens
def normalize_colors(identity: Iterable[str] | str | None) -> list[str]:
tokens = _extract_tokens(identity)
if not tokens:
return []
seen: set[str] = set()
collected: list[str] = []
for token in tokens:
if token in _WUBRG_ORDER and token not in seen:
seen.add(token)
collected.append(token)
return [color for color in _WUBRG_ORDER if color in seen]
def canon_color_code(identity: Iterable[str] | str | None) -> str:
tokens = _extract_tokens(identity)
if not tokens:
return "C"
ordered = [color for color in _WUBRG_ORDER if color in tokens]
if ordered:
return "".join(ordered)
if "C" in tokens:
return "C"
return "C"
def color_label_from_code(code: str) -> str:
if not code:
return ""
if code == "C":
return "Colorless (C)"
if len(code) == 1:
base = _COLOR_NAMES.get(code, code)
return f"{base} ({code})"
if len(code) == 2:
label = _TWO_COLOR_LABELS.get(code)
if label:
return f"{label} ({code})"
if len(code) == 3:
label = _THREE_COLOR_LABELS.get(code)
if label:
return f"{label} ({code})"
if len(code) == 4:
label = _FOUR_COLOR_LABELS.get(code)
if label:
return f"{label} ({code})"
if code == "WUBRG":
return "Five-Color (WUBRG)"
parts = [_COLOR_NAMES.get(ch, ch) for ch in code]
pretty = " / ".join(parts)
return f"{pretty} ({code})"
def format_color_label(identity: Iterable[str] | str | None) -> str:
return color_label_from_code(canon_color_code(identity))

View file

@ -0,0 +1,325 @@
"""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",
]

View file

@ -0,0 +1,287 @@
"""Utilities for detecting partner and background mechanics from card data."""
from __future__ import annotations
from dataclasses import dataclass
import math
import re
from typing import Any, Iterable, Tuple, List
__all__ = [
"PartnerBackgroundInfo",
"analyze_partner_background",
"extract_partner_with_names",
]
_PARTNER_PATTERN = re.compile(r"\bPartner\b(?!\s+with)", re.IGNORECASE)
_PARTNER_WITH_PATTERN = re.compile(r"\bPartner with ([^.;\n]+)", re.IGNORECASE)
_CHOOSE_BACKGROUND_PATTERN = re.compile(r"\bChoose a Background\b", re.IGNORECASE)
_BACKGROUND_KEYWORD_PATTERN = re.compile(r"\bBackground\b", re.IGNORECASE)
_FRIENDS_FOREVER_PATTERN = re.compile(r"\bFriends forever\b", re.IGNORECASE)
_DOCTORS_COMPANION_PATTERN = re.compile(r"Doctor's companion", re.IGNORECASE)
_PARTNER_RESTRICTION_PATTERN = re.compile(r"\bPartner\b\s*(?:—|-||:)", re.IGNORECASE)
_PARTNER_RESTRICTION_CAPTURE = re.compile(
r"\bPartner\b\s*(?:—|-||:)\s*([^.;\n\r(]+)",
re.IGNORECASE,
)
_PLAIN_PARTNER_THEME_TOKENS = {
"partner",
"partners",
}
_PARTNER_THEME_TOKENS = {
"partner",
"partners",
"friends forever",
"doctor's companion",
}
def _normalize_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
text = value
elif isinstance(value, float):
if math.isnan(value):
return ""
text = str(value)
else:
text = str(value)
stripped = text.strip()
if stripped.casefold() == "nan":
return ""
return text
def _is_background_theme_tag(tag: str) -> bool:
text = (tag or "").strip().casefold()
if not text:
return False
if "background" not in text:
return False
if "choose a background" in text:
return False
if "backgrounds matter" in text:
return False
normalized = text.replace("", "-").replace("", "-")
if normalized in {"background", "backgrounds", "background card", "background (card type)"}:
return True
if normalized.startswith("background -") or normalized.startswith("background:"):
return True
if normalized.endswith(" background"):
return True
return False
@dataclass(frozen=True)
class PartnerBackgroundInfo:
"""Aggregated partner/background detection result."""
has_partner: bool
partner_with: Tuple[str, ...]
choose_background: bool
is_background: bool
is_doctor: bool
is_doctors_companion: bool
has_plain_partner: bool
has_restricted_partner: bool
restricted_partner_labels: Tuple[str, ...]
def _normalize_theme_tags(tags: Iterable[str]) -> Tuple[str, ...]:
return tuple(tag.strip().lower() for tag in tags if str(tag).strip())
def extract_partner_with_names(oracle_text: str) -> Tuple[str, ...]:
"""Extract partner-with names from oracle text.
Handles mixed separators ("and", "or", "&", "/") while preserving card
names that include commas (e.g., "Pir, Imaginative Rascal"). Reminder text in
parentheses is stripped and results are deduplicated while preserving order.
"""
text = _normalize_text(oracle_text)
if not text:
return tuple()
names: list[str] = []
seen: set[str] = set()
for match in _PARTNER_WITH_PATTERN.finditer(text):
raw_targets = match.group(1)
# Remove reminder text and trailing punctuation
until_paren = raw_targets.split("(", 1)[0]
base_text = until_paren.strip().strip(". ")
if not base_text:
continue
segments = re.split(r"\s*(?:\band\b|\bor\b|\bplus\b|&|/|\+)\s*", base_text, flags=re.IGNORECASE)
buffer: List[str] = []
for token in segments:
buffer.extend(_split_partner_token(token))
for item in buffer:
cleaned = item.strip().strip("., ")
if not cleaned:
continue
lowered = cleaned.casefold()
if lowered in seen:
continue
seen.add(lowered)
names.append(cleaned)
return tuple(names)
_SIMPLE_NAME_TOKEN = re.compile(r"^[A-Za-z0-9'\-]+$")
def _split_partner_token(token: str) -> List[str]:
cleaned = (token or "").strip()
if not cleaned:
return []
cleaned = cleaned.strip(",.; ")
if not cleaned:
return []
parts = [part.strip() for part in cleaned.split(",") if part.strip()]
if len(parts) <= 1:
return parts
if all(_SIMPLE_NAME_TOKEN.fullmatch(part) for part in parts):
return parts
return [cleaned]
def _has_plain_partner_keyword(oracle_text: str) -> bool:
oracle_text = _normalize_text(oracle_text)
if not oracle_text:
return False
for raw_line in oracle_text.splitlines():
line = raw_line.strip()
if not line:
continue
ability = line.split("(", 1)[0].strip()
if not ability:
continue
lowered = ability.casefold()
if lowered.startswith("partner with"):
continue
if lowered.startswith("partner"):
suffix = ability[7:].strip()
if suffix and suffix[0] in {"-", "", "", ":"}:
continue
if suffix:
# Contains additional text beyond plain Partner keyword
continue
return True
return False
def _has_partner_restriction(oracle_text: str) -> bool:
oracle_text = _normalize_text(oracle_text)
if not oracle_text:
return False
return bool(_PARTNER_RESTRICTION_PATTERN.search(oracle_text))
def analyze_partner_background(
type_line: str | None,
oracle_text: str | None,
theme_tags: Iterable[str] | None = None,
) -> PartnerBackgroundInfo:
"""Detect partner/background mechanics using text and theme tags."""
normalized_tags = _normalize_theme_tags(theme_tags or ())
partner_with = extract_partner_with_names(oracle_text or "")
type_line_text = _normalize_text(type_line)
oracle_text_value = _normalize_text(oracle_text)
choose_background = bool(_CHOOSE_BACKGROUND_PATTERN.search(oracle_text_value))
theme_partner = any(tag in _PARTNER_THEME_TOKENS for tag in normalized_tags)
theme_plain_partner = any(tag in _PLAIN_PARTNER_THEME_TOKENS for tag in normalized_tags)
theme_choose_background = any("choose a background" in tag for tag in normalized_tags)
theme_is_background = any(_is_background_theme_tag(tag) for tag in normalized_tags)
friends_forever = bool(_FRIENDS_FOREVER_PATTERN.search(oracle_text_value))
theme_friends_forever = any(tag == "friends forever" for tag in normalized_tags)
plain_partner_keyword = _has_plain_partner_keyword(oracle_text_value)
has_plain_partner = bool(plain_partner_keyword or theme_plain_partner)
partner_restriction_keyword = _has_partner_restriction(oracle_text_value)
restricted_labels = _collect_restricted_partner_labels(oracle_text_value, theme_tags)
has_restricted_partner = bool(
partner_with
or partner_restriction_keyword
or friends_forever
or theme_friends_forever
or restricted_labels
)
creature_segment = ""
if type_line_text:
if "" in type_line_text:
creature_segment = type_line_text.split("", 1)[1]
elif "-" in type_line_text:
creature_segment = type_line_text.split("-", 1)[1]
else:
creature_segment = type_line_text
type_tokens = {part.strip().lower() for part in creature_segment.split() if part.strip()}
has_time_lord_doctor = {"time", "lord", "doctor"}.issubset(type_tokens)
is_doctor = bool(has_time_lord_doctor)
is_doctors_companion = bool(_DOCTORS_COMPANION_PATTERN.search(oracle_text_value))
if not is_doctors_companion:
is_doctors_companion = any("doctor" in tag and "companion" in tag for tag in normalized_tags)
has_partner = bool(has_plain_partner or has_restricted_partner or theme_partner)
choose_background = choose_background or theme_choose_background
is_background = bool(_BACKGROUND_KEYWORD_PATTERN.search(type_line_text)) or theme_is_background
return PartnerBackgroundInfo(
has_partner=has_partner,
partner_with=partner_with,
choose_background=choose_background,
is_background=is_background,
is_doctor=is_doctor,
is_doctors_companion=is_doctors_companion,
has_plain_partner=has_plain_partner,
has_restricted_partner=has_restricted_partner,
restricted_partner_labels=restricted_labels,
)
def _collect_restricted_partner_labels(
oracle_text: str,
theme_tags: Iterable[str] | None,
) -> Tuple[str, ...]:
labels: list[str] = []
seen: set[str] = set()
def _maybe_add(raw: str | None) -> None:
if not raw:
return
cleaned = raw.strip().strip("-—–: ")
if not cleaned:
return
key = cleaned.casefold()
if key in seen:
return
seen.add(key)
labels.append(cleaned)
oracle_text = _normalize_text(oracle_text)
for match in _PARTNER_RESTRICTION_CAPTURE.finditer(oracle_text):
value = match.group(1)
value = value.split("(", 1)[0]
value = value.strip().rstrip(".,;:—- ")
_maybe_add(value)
if theme_tags:
for tag in theme_tags:
text = _normalize_text(tag).strip()
if not text:
continue
lowered = text.casefold()
if not lowered.startswith("partner"):
continue
parts = re.split(r"[—\-:]", text, maxsplit=1)
if len(parts) < 2:
continue
_maybe_add(parts[1])
return tuple(labels)

View 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()

View file

@ -7,7 +7,8 @@ import datetime as _dt
import re as _re
import logging_util
from code.deck_builder.summary_telemetry import record_land_summary, record_theme_summary
from code.deck_builder.summary_telemetry import record_land_summary, record_theme_summary, record_partner_summary
from code.deck_builder.color_identity_utils import normalize_colors, canon_color_code, color_label_from_code
from code.deck_builder.shared_copy import build_land_headline, dfc_card_note
logger = logging_util.logging.getLogger(__name__)
@ -27,6 +28,144 @@ class ReportingMixin:
"""Public method for orchestration: delegates to print_type_summary and print_card_library."""
self.print_type_summary()
self.print_card_library(table=True)
def get_commander_export_metadata(self) -> Dict[str, Any]:
"""Return metadata describing the active commander configuration for export surfaces."""
def _clean(value: object) -> str:
try:
text = str(value).strip()
except Exception:
text = ""
return text
metadata: Dict[str, Any] = {
"primary_commander": None,
"secondary_commander": None,
"commander_names": [],
"partner_mode": None,
"color_identity": [],
}
combined = getattr(self, 'combined_commander', None)
commander_names: list[str] = []
primary_name = None
secondary_name = None
if combined is not None:
primary_name = _clean(getattr(combined, 'primary_name', '')) or None
secondary_name = _clean(getattr(combined, 'secondary_name', '')) or None
partner_mode_obj = getattr(combined, 'partner_mode', None)
partner_mode_val = getattr(partner_mode_obj, 'value', None)
if isinstance(partner_mode_val, str) and partner_mode_val.strip():
metadata["partner_mode"] = partner_mode_val.strip()
elif isinstance(partner_mode_obj, str) and partner_mode_obj.strip():
metadata["partner_mode"] = partner_mode_obj.strip()
if primary_name:
commander_names.append(primary_name)
if secondary_name and all(secondary_name.casefold() != n.casefold() for n in commander_names):
commander_names.append(secondary_name)
combined_identity_raw = list(getattr(combined, 'color_identity', []) or [])
combined_colors = normalize_colors(combined_identity_raw)
primary_colors = normalize_colors(getattr(combined, 'primary_color_identity', ()))
secondary_colors = normalize_colors(getattr(combined, 'secondary_color_identity', ()))
color_code = getattr(combined, 'color_code', '') or canon_color_code(combined_identity_raw)
color_label = getattr(combined, 'color_label', '') or color_label_from_code(color_code)
mode_lower = (metadata["partner_mode"] or "").lower() if metadata.get("partner_mode") else ""
if mode_lower == "background":
secondary_role = "background"
elif mode_lower == "doctor_companion":
secondary_role = "companion"
elif mode_lower == "partner_with":
secondary_role = "partner_with"
elif mode_lower == "partner":
secondary_role = "partner"
else:
secondary_role = "secondary"
secondary_role_label_map = {
"background": "Background",
"companion": "Doctor pairing",
"partner_with": "Partner With",
"partner": "Partner commander",
}
secondary_role_label = secondary_role_label_map.get(secondary_role, "Partner commander")
color_sources: list[Dict[str, Any]] = []
for color in combined_colors:
providers: list[Dict[str, Any]] = []
if primary_name and color in primary_colors:
providers.append({"name": primary_name, "role": "primary"})
if secondary_name and color in secondary_colors:
providers.append({"name": secondary_name, "role": secondary_role})
if not providers and primary_name:
providers.append({"name": primary_name, "role": "primary"})
color_sources.append({"color": color, "providers": providers})
added_colors = [c for c in combined_colors if c not in primary_colors]
removed_colors = [c for c in primary_colors if c not in combined_colors]
combined_payload = {
"primary_name": primary_name,
"secondary_name": secondary_name,
"partner_mode": metadata["partner_mode"],
"color_identity": combined_identity_raw,
"theme_tags": list(getattr(combined, 'theme_tags', []) or []),
"raw_tags_primary": list(getattr(combined, 'raw_tags_primary', []) or []),
"raw_tags_secondary": list(getattr(combined, 'raw_tags_secondary', []) or []),
"warnings": list(getattr(combined, 'warnings', []) or []),
"color_code": color_code,
"color_label": color_label,
"primary_color_identity": primary_colors,
"secondary_color_identity": secondary_colors,
"secondary_role": secondary_role,
"secondary_role_label": secondary_role_label,
"color_sources": color_sources,
"color_delta": {
"added": added_colors,
"removed": removed_colors,
"primary": primary_colors,
"secondary": secondary_colors,
},
}
metadata["combined_commander"] = combined_payload
else:
primary_attr = _clean(getattr(self, 'commander_name', '') or getattr(self, 'commander', ''))
if primary_attr:
primary_name = primary_attr
commander_names.append(primary_attr)
secondary_attr = _clean(getattr(self, 'secondary_commander', ''))
if secondary_attr and all(secondary_attr.casefold() != n.casefold() for n in commander_names):
secondary_name = secondary_attr
commander_names.append(secondary_attr)
partner_mode_attr = getattr(self, 'partner_mode', None)
partner_mode_val = getattr(partner_mode_attr, 'value', None)
if isinstance(partner_mode_val, str) and partner_mode_val.strip():
metadata["partner_mode"] = partner_mode_val.strip()
elif isinstance(partner_mode_attr, str) and partner_mode_attr.strip():
metadata["partner_mode"] = partner_mode_attr.strip()
metadata["primary_commander"] = primary_name
metadata["secondary_commander"] = secondary_name
metadata["commander_names"] = commander_names
if metadata["partner_mode"]:
metadata["partner_mode"] = metadata["partner_mode"].lower()
# Prefer combined color identity when available
color_source = None
if combined is not None:
color_source = getattr(combined, 'color_identity', None)
if not color_source:
color_source = getattr(self, 'combined_color_identity', None)
if not color_source:
color_source = getattr(self, 'color_identity', None)
if color_source:
metadata["color_identity"] = [str(c).strip().upper() for c in color_source if str(c).strip()]
return metadata
"""Phase 6: Reporting, summaries, and export helpers."""
def enforce_and_reexport(self, base_stem: str | None = None, mode: str = "prompt") -> dict:
@ -623,6 +762,27 @@ class ReportingMixin:
'colors': list(getattr(self, 'color_identity', []) or []),
'include_exclude_summary': include_exclude_summary,
}
try:
commander_meta = self.get_commander_export_metadata()
except Exception:
commander_meta = {}
commander_names = commander_meta.get('commander_names') or []
if commander_names:
summary_payload['commander'] = {
'names': commander_names,
'primary': commander_meta.get('primary_commander'),
'secondary': commander_meta.get('secondary_commander'),
'partner_mode': commander_meta.get('partner_mode'),
'color_identity': commander_meta.get('color_identity') or list(getattr(self, 'color_identity', []) or []),
}
combined_payload = commander_meta.get('combined_commander')
if combined_payload:
summary_payload['commander']['combined'] = combined_payload
try:
record_partner_summary(summary_payload['commander'])
except Exception: # pragma: no cover - diagnostics only
logger.debug("Failed to record partner telemetry", exc_info=True)
try:
record_land_summary(land_summary)
except Exception: # pragma: no cover - diagnostics only
@ -721,6 +881,17 @@ class ReportingMixin:
"Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","Text","DFCNote","Owned"
]
header_suffix: List[str] = []
try:
commander_meta = self.get_commander_export_metadata()
except Exception:
commander_meta = {}
commander_names = commander_meta.get('commander_names') or []
if commander_names:
header_suffix.append(f"Commanders: {', '.join(commander_names)}")
header_row = headers + header_suffix
suffix_padding = [''] * len(header_suffix)
# Precedence list for sorting
precedence_order = [
'Commander', 'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land'
@ -853,9 +1024,12 @@ class ReportingMixin:
with open(fname, 'w', newline='', encoding='utf-8') as f:
w = csv.writer(f)
w.writerow(headers)
w.writerow(header_row)
for _, data_row in rows:
w.writerow(data_row)
if suffix_padding:
w.writerow(data_row + suffix_padding)
else:
w.writerow(data_row)
self.output_func(f"Deck exported to {fname}")
# Auto-generate matching plaintext list (best-effort; ignore failures)
@ -979,7 +1153,24 @@ class ReportingMixin:
sortable.append(((prec, name.lower()), name, info.get('Count',1), dfc_note))
sortable.sort(key=lambda x: x[0])
try:
commander_meta = self.get_commander_export_metadata()
except Exception:
commander_meta = {}
header_lines: List[str] = []
commander_names = commander_meta.get('commander_names') or []
if commander_names:
header_lines.append(f"# Commanders: {', '.join(commander_names)}")
partner_mode = commander_meta.get('partner_mode')
if partner_mode and partner_mode not in (None, '', 'none'):
header_lines.append(f"# Partner Mode: {partner_mode}")
color_identity = commander_meta.get('color_identity') or []
if color_identity:
header_lines.append(f"# Colors: {', '.join(color_identity)}")
with open(path, 'w', encoding='utf-8') as f:
if header_lines:
f.write("\n".join(header_lines) + "\n\n")
for _, name, count, dfc_note in sortable:
line = f"{count} {name}"
if dfc_note:
@ -1001,6 +1192,9 @@ class ReportingMixin:
- add_lands, add_creatures, add_non_creature_spells (defaults True)
- fetch_count (if determined during run)
- ideal_counts (the actual ideal composition values used)
- secondary_commander (when partner mechanics apply)
- background (when Choose a Background is used)
- enable_partner_mechanics flag (bool, default False)
"""
os.makedirs(directory, exist_ok=True)
@ -1019,6 +1213,26 @@ class ReportingMixin:
return candidate
i += 1
def _clean_text(value: object | None) -> str | None:
if value is None:
return None
if isinstance(value, str):
text = value.strip()
if not text:
return None
if text.lower() == "none":
return None
return text
try:
text = str(value).strip()
except Exception:
return None
if not text:
return None
if text.lower() == "none":
return None
return text
if filename is None:
# Prefer a custom export base when present; else commander/themes
try:
@ -1059,6 +1273,57 @@ class ReportingMixin:
]
theme_catalog_version = getattr(self, 'theme_catalog_version', None)
partner_enabled_flag = bool(getattr(self, 'partner_feature_enabled', False))
requested_secondary = _clean_text(getattr(self, 'requested_secondary_commander', None))
requested_background = _clean_text(getattr(self, 'requested_background', None))
stored_secondary = _clean_text(getattr(self, 'secondary_commander', None))
stored_background = _clean_text(getattr(self, 'background', None))
metadata: Dict[str, Any] = {}
try:
metadata_candidate = self.get_commander_export_metadata()
except Exception:
metadata_candidate = {}
if isinstance(metadata_candidate, dict):
metadata = metadata_candidate
partner_mode = str(metadata.get("partner_mode") or "").strip().lower() if metadata else ""
metadata_secondary = _clean_text(metadata.get("secondary_commander")) if metadata else None
combined_secondary = None
combined_info = metadata.get("combined_commander") if metadata else None
if isinstance(combined_info, dict):
combined_secondary = _clean_text(combined_info.get("secondary_name"))
if partner_mode and partner_mode not in {"none", ""}:
partner_enabled_flag = True if not partner_enabled_flag else partner_enabled_flag
secondary_for_export = None
background_for_export = None
if partner_mode == "background":
background_for_export = (
combined_secondary
or requested_background
or metadata_secondary
or stored_background
or stored_secondary
)
else:
secondary_for_export = (
combined_secondary
or requested_secondary
or metadata_secondary
or stored_secondary
)
background_for_export = requested_background or stored_background
secondary_for_export = _clean_text(secondary_for_export)
background_for_export = _clean_text(background_for_export)
if partner_mode == "background":
secondary_for_export = None
enable_partner_flag = bool(partner_enabled_flag)
payload = {
"commander": getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '',
"primary_tag": getattr(self, 'primary_tag', None),
@ -1086,6 +1351,9 @@ class ReportingMixin:
# CamelCase aliases for downstream consumers (web diagnostics, external tooling)
"userThemes": user_themes,
"themeCatalogVersion": theme_catalog_version,
"secondary_commander": secondary_for_export,
"background": background_for_export,
"enable_partner_mechanics": enable_partner_flag,
# chosen fetch land count (others intentionally omitted for variance)
"fetch_count": chosen_fetch,
# actual ideal counts used for this run

View file

@ -1550,6 +1550,28 @@ def build_random_full_deck(
custom_base = None
if isinstance(custom_base, str) and custom_base.strip():
meta_payload["name"] = custom_base.strip()
try:
commander_meta = builder.get_commander_export_metadata() # type: ignore[attr-defined]
except Exception:
commander_meta = {}
names = commander_meta.get("commander_names") or []
if names:
meta_payload["commander_names"] = names
combined_payload = commander_meta.get("combined_commander")
if combined_payload:
meta_payload["combined_commander"] = combined_payload
partner_mode = commander_meta.get("partner_mode")
if partner_mode:
meta_payload["partner_mode"] = partner_mode
color_identity = commander_meta.get("color_identity")
if color_identity:
meta_payload["color_identity"] = color_identity
primary_commander = commander_meta.get("primary_commander")
if primary_commander:
meta_payload["commander"] = primary_commander
secondary_commander = commander_meta.get("secondary_commander")
if secondary_commander:
meta_payload["secondary_commander"] = secondary_commander
return meta_payload
# Attempt to reuse existing export performed inside builder (headless run already exported)

View file

@ -0,0 +1,662 @@
"""Partner suggestion scoring helpers.
This module provides a scoring helper that ranks potential partner/background
pairings for a selected primary commander. It consumes the normalized metadata
emitted by ``build_partner_suggestions.py`` (themes, role tags, partner flags,
and pairing telemetry) and blends several weighted components:
* Shared theme overlap (normalized Jaccard/role-aware) baseline synergy.
* Theme adjacency (deck export co-occurrence + curated overrides).
* Color compatibility (prefers compact color changes).
* Mechanic affinity (Partner With, Doctor/Companion, Background matches).
* Penalties (illegal configurations, missing tags, restricted conflicts).
Weights are mode-specific so future tuning can adjust emphasis without
rewriting the algorithm. The public ``score_partner_candidate`` helper returns
both the aggregate score and a component breakdown for diagnostics.
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from typing import Dict, Iterable, Mapping, MutableMapping, Sequence
from .combined_commander import PartnerMode
__all__ = [
"PartnerSuggestionContext",
"ScoreWeights",
"ScoreResult",
"MODE_WEIGHTS",
"score_partner_candidate",
"is_noise_theme",
]
def _clean_str(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def _normalize_token(value: str | None) -> str:
return _clean_str(value).casefold()
def _commander_name(payload: Mapping[str, object]) -> str:
name = _clean_str(payload.get("display_name")) or _clean_str(payload.get("name"))
return name or "Unknown Commander"
def _commander_key(payload: Mapping[str, object]) -> str:
return _normalize_token(_commander_name(payload))
def _sequence(payload: Mapping[str, object], key: str) -> tuple[str, ...]:
raw = payload.get(key)
if raw is None:
return tuple()
if isinstance(raw, (list, tuple)):
return tuple(_clean_str(item) for item in raw if _clean_str(item))
return tuple(filter(None, (_clean_str(raw),)))
_EXCLUDED_THEME_TOKENS = {
"legends matter",
"historics matter",
"partner",
"partner - survivors",
}
def _theme_should_be_excluded(theme: str) -> bool:
token = _normalize_token(theme)
if not token:
return False
if token in _EXCLUDED_THEME_TOKENS:
return True
return "kindred" in token
def is_noise_theme(theme: str | None) -> bool:
"""Return True when the provided theme is considered too generic/noisy.
The partner suggestion UI should suppress these themes from overlap summaries to
keep recommendations focused on distinctive archetypes.
"""
if theme is None:
return False
return _theme_should_be_excluded(theme)
def _theme_sequence(payload: Mapping[str, object], key: str = "themes") -> tuple[str, ...]:
return tuple(
theme
for theme in _sequence(payload, key)
if not _theme_should_be_excluded(theme)
)
def _normalize_string_set(values: Iterable[str]) -> tuple[str, ...]:
seen: set[str] = set()
collected: list[str] = []
for value in values:
token = _clean_str(value)
if not token:
continue
key = token.casefold()
if key in seen:
continue
seen.add(key)
collected.append(token)
return tuple(collected)
@dataclass(frozen=True)
class ScoreWeights:
"""Weight multipliers for each scoring component."""
overlap: float
synergy: float
color: float
affinity: float
penalty: float
@dataclass(frozen=True)
class ScoreResult:
"""Result returned by :func:`score_partner_candidate`."""
score: float
mode: PartnerMode
components: Mapping[str, float]
notes: tuple[str, ...]
weights: ScoreWeights
class PartnerSuggestionContext:
"""Container for suggestion dataset fragments used during scoring."""
def __init__(
self,
*,
theme_cooccurrence: Mapping[str, Mapping[str, int]] | None = None,
pairing_counts: Mapping[tuple[str, str, str], int] | None = None,
curated_synergy: Mapping[tuple[str, str], float] | None = None,
) -> None:
self._theme_cooccurrence: Dict[str, Dict[str, float]] = {}
self._pairing_counts: Dict[tuple[str, str, str], float] = {}
self._curated_synergy: Dict[tuple[str, str], float] = {}
max_co = 0
if theme_cooccurrence:
for theme, neighbors in theme_cooccurrence.items():
theme_key = _normalize_token(theme)
if not theme_key:
continue
store: Dict[str, float] = {}
for other, count in neighbors.items():
other_key = _normalize_token(other)
if not other_key:
continue
value = float(count or 0)
if value <= 0:
continue
store[other_key] = value
max_co = max(max_co, value)
if store:
self._theme_cooccurrence[theme_key] = store
self._theme_co_max = max(max_co, 1.0)
max_pair = 0
if pairing_counts:
for key, count in pairing_counts.items():
if not isinstance(key, tuple) or len(key) != 3:
continue
mode, primary, secondary = key
norm_key = (
_normalize_token(mode),
_normalize_token(primary),
_normalize_token(secondary),
)
value = float(count or 0)
if value <= 0:
continue
self._pairing_counts[norm_key] = value
# Store symmetric entry to simplify lookups.
symmetric = (
_normalize_token(mode),
_normalize_token(secondary),
_normalize_token(primary),
)
self._pairing_counts[symmetric] = value
max_pair = max(max_pair, value)
self._pairing_max = max(max_pair, 1.0)
if curated_synergy:
for key, value in curated_synergy.items():
if not isinstance(key, tuple) or len(key) != 2:
continue
primary, secondary = key
normalized = (
_normalize_token(primary),
_normalize_token(secondary),
)
if value is None:
continue
magnitude = max(0.0, float(value))
if magnitude <= 0:
continue
self._curated_synergy[normalized] = min(1.0, magnitude)
self._curated_synergy[(normalized[1], normalized[0])] = min(1.0, magnitude)
@classmethod
def from_dataset(cls, payload: Mapping[str, object] | None) -> "PartnerSuggestionContext":
if not payload:
return cls()
themes_raw = payload.get("themes")
theme_cooccurrence: Dict[str, Dict[str, int]] = {}
if isinstance(themes_raw, Mapping):
for theme_key, entry in themes_raw.items():
co = entry.get("co_occurrence") if isinstance(entry, Mapping) else None
if not isinstance(co, Mapping):
continue
inner: Dict[str, int] = {}
for other, info in co.items():
if isinstance(info, Mapping):
count = info.get("count")
else:
count = info
try:
inner[str(other)] = int(count)
except Exception:
continue
theme_cooccurrence[str(theme_key)] = inner
pairings = payload.get("pairings")
pairing_counts: Dict[tuple[str, str, str], int] = {}
if isinstance(pairings, Mapping):
records = pairings.get("records")
if isinstance(records, Sequence):
for entry in records:
if not isinstance(entry, Mapping):
continue
mode = str(entry.get("mode", "unknown"))
primary = str(entry.get("primary_canonical") or entry.get("primary") or "")
secondary = str(entry.get("secondary_canonical") or entry.get("secondary") or "")
if not primary or not secondary:
continue
try:
count = int(entry.get("count", 0))
except Exception:
continue
pairing_counts[(mode, primary, secondary)] = count
curated = payload.get("curated_overrides")
curated_synergy: Dict[tuple[str, str], float] = {}
if isinstance(curated, Mapping):
entries = curated.get("entries")
if isinstance(entries, Mapping):
for raw_key, raw_value in entries.items():
if not isinstance(raw_key, str):
continue
parts = [part.strip() for part in raw_key.split("::") if part.strip()]
if len(parts) != 2:
continue
try:
magnitude = float(raw_value)
except Exception:
continue
curated_synergy[(parts[0], parts[1])] = magnitude
return cls(
theme_cooccurrence=theme_cooccurrence,
pairing_counts=pairing_counts,
curated_synergy=curated_synergy,
)
@lru_cache(maxsize=256)
def theme_synergy(self, theme_a: str, theme_b: str) -> float:
key_a = _normalize_token(theme_a)
key_b = _normalize_token(theme_b)
if not key_a or not key_b or key_a == key_b:
return 0.0
co = self._theme_cooccurrence.get(key_a, {})
value = co.get(key_b, 0.0)
normalized = value / self._theme_co_max
curated = self._curated_synergy.get((key_a, key_b), 0.0)
return max(0.0, min(1.0, max(normalized, curated)))
@lru_cache(maxsize=128)
def pairing_strength(self, mode: PartnerMode, primary: str, secondary: str) -> float:
key = (
mode.value,
_normalize_token(primary),
_normalize_token(secondary),
)
value = self._pairing_counts.get(key, 0.0)
return max(0.0, min(1.0, value / self._pairing_max))
DEFAULT_WEIGHTS = ScoreWeights(
overlap=0.45,
synergy=0.25,
color=0.15,
affinity=0.10,
penalty=0.20,
)
MODE_WEIGHTS: Mapping[PartnerMode, ScoreWeights] = {
PartnerMode.PARTNER: DEFAULT_WEIGHTS,
PartnerMode.PARTNER_WITH: ScoreWeights(overlap=0.40, synergy=0.20, color=0.10, affinity=0.20, penalty=0.25),
PartnerMode.BACKGROUND: ScoreWeights(overlap=0.50, synergy=0.30, color=0.10, affinity=0.10, penalty=0.25),
PartnerMode.DOCTOR_COMPANION: ScoreWeights(overlap=0.30, synergy=0.20, color=0.10, affinity=0.30, penalty=0.25),
PartnerMode.NONE: DEFAULT_WEIGHTS,
}
def _clamp(value: float, minimum: float = 0.0, maximum: float = 1.0) -> float:
if value < minimum:
return minimum
if value > maximum:
return maximum
return value
def score_partner_candidate(
primary: Mapping[str, object],
candidate: Mapping[str, object],
*,
mode: PartnerMode | str | None = None,
context: PartnerSuggestionContext | None = None,
) -> ScoreResult:
"""Score a partner/background candidate for the provided primary.
Args:
primary: Commander metadata dictionary (as produced by the dataset).
candidate: Potential partner/background metadata dictionary.
mode: Desired partner mode (auto-detected when omitted).
context: Optional suggestion context providing theme/pairing statistics.
Returns:
ScoreResult with aggregate score ``0.0`` ``1.0`` and component details.
"""
mode = _resolve_mode(primary, candidate, mode)
weights = MODE_WEIGHTS.get(mode, DEFAULT_WEIGHTS)
ctx = context or PartnerSuggestionContext()
overlap = _theme_overlap(primary, candidate)
synergy = _theme_synergy(primary, candidate, ctx)
color_value = _color_compatibility(primary, candidate)
affinity, affinity_notes, affinity_penalties = _mechanic_affinity(primary, candidate, mode, ctx)
penalty_value, penalty_notes = _collect_penalties(primary, candidate, mode, affinity_penalties)
positive_total = weights.overlap + weights.synergy + weights.color + weights.affinity
positive_total = positive_total or 1.0
blended = (
weights.overlap * overlap
+ weights.synergy * synergy
+ weights.color * color_value
+ weights.affinity * affinity
) / positive_total
adjusted = blended - weights.penalty * penalty_value
final_score = _clamp(adjusted)
notes = tuple(note for note in (*affinity_notes, *penalty_notes) if note)
components = {
"overlap": overlap,
"synergy": synergy,
"color": color_value,
"affinity": affinity,
"penalty": penalty_value,
}
return ScoreResult(
score=final_score,
mode=mode,
components=components,
notes=notes,
weights=weights,
)
def _resolve_mode(
primary: Mapping[str, object],
candidate: Mapping[str, object],
provided: PartnerMode | str | None,
) -> PartnerMode:
if isinstance(provided, PartnerMode):
return provided
if isinstance(provided, str) and provided:
normalized = provided.replace("-", "_").strip().casefold()
for mode in PartnerMode:
if mode.value == normalized:
return mode
partner_meta_primary = _partner_meta(primary)
partner_meta_candidate = _partner_meta(candidate)
candidate_name = _commander_name(candidate)
if partner_meta_candidate.get("is_background"):
return PartnerMode.BACKGROUND
partner_with = {
_normalize_token(name)
for name in partner_meta_primary.get("partner_with", [])
}
if partner_with and _normalize_token(candidate_name) in partner_with:
return PartnerMode.PARTNER_WITH
if partner_meta_primary.get("is_doctor") and partner_meta_candidate.get("is_doctors_companion"):
return PartnerMode.DOCTOR_COMPANION
if partner_meta_primary.get("is_doctors_companion") and partner_meta_candidate.get("is_doctor"):
return PartnerMode.DOCTOR_COMPANION
if partner_meta_primary.get("has_partner") and partner_meta_candidate.get("has_partner"):
return PartnerMode.PARTNER
if partner_meta_candidate.get("supports_backgrounds") and partner_meta_primary.get("is_background"):
return PartnerMode.BACKGROUND
if partner_meta_candidate.get("has_partner"):
return PartnerMode.PARTNER
return PartnerMode.PARTNER
def _partner_meta(payload: Mapping[str, object]) -> MutableMapping[str, object]:
meta = payload.get("partner")
if isinstance(meta, Mapping):
return dict(meta)
return {}
def _theme_overlap(primary: Mapping[str, object], candidate: Mapping[str, object]) -> float:
theme_primary = {
_normalize_token(theme)
for theme in _theme_sequence(primary)
}
theme_candidate = {
_normalize_token(theme)
for theme in _theme_sequence(candidate)
}
theme_primary.discard("")
theme_candidate.discard("")
role_primary = {
_normalize_token(tag)
for tag in _sequence(primary, "role_tags")
}
role_candidate = {
_normalize_token(tag)
for tag in _sequence(candidate, "role_tags")
}
role_primary.discard("")
role_candidate.discard("")
# Base Jaccard over theme tags.
union = theme_primary | theme_candidate
if not union:
base = 0.0
else:
base = len(theme_primary & theme_candidate) / len(union)
# Role-aware bonus (weighted at 30% of overlap component).
role_union = role_primary | role_candidate
if not role_union:
role_score = 0.0
else:
role_score = len(role_primary & role_candidate) / len(role_union)
combined = 0.7 * base + 0.3 * role_score
return _clamp(combined)
def _theme_synergy(
primary: Mapping[str, object],
candidate: Mapping[str, object],
context: PartnerSuggestionContext,
) -> float:
themes_primary = _theme_sequence(primary)
themes_candidate = _theme_sequence(candidate)
if not themes_primary or not themes_candidate:
return 0.0
total = 0.0
weight = 0
for theme_a in themes_primary:
for theme_b in themes_candidate:
value = context.theme_synergy(theme_a, theme_b)
if value <= 0:
continue
total += value
weight += 1
if weight == 0:
return 0.0
average = total / weight
# Observed pairing signal augments synergy.
primary_name = _commander_name(primary)
candidate_name = _commander_name(candidate)
observed_partner = context.pairing_strength(PartnerMode.PARTNER, primary_name, candidate_name)
observed_background = context.pairing_strength(PartnerMode.BACKGROUND, primary_name, candidate_name)
observed_doctor = context.pairing_strength(PartnerMode.DOCTOR_COMPANION, primary_name, candidate_name)
observed_any = max(observed_partner, observed_background, observed_doctor)
return _clamp(max(average, observed_any))
def _color_compatibility(primary: Mapping[str, object], candidate: Mapping[str, object]) -> float:
primary_colors = {
_clean_str(color).upper()
for color in _sequence(primary, "color_identity")
}
candidate_colors = {
_clean_str(color).upper()
for color in _sequence(candidate, "color_identity")
}
if not candidate_colors:
# Colorless partners still provide value when primary is colored.
return 0.6 if primary_colors else 0.0
overlap = primary_colors & candidate_colors
union = primary_colors | candidate_colors
overlap_ratio = len(overlap) / max(len(candidate_colors), 1)
added_colors = len(union) - len(primary_colors)
if added_colors <= 0:
delta = 1.0
elif added_colors == 1:
delta = 0.75
elif added_colors == 2:
delta = 0.45
else:
delta = 0.20
colorless_bonus = 0.1 if candidate_colors == {"C"} else 0.0
blended = 0.6 * overlap_ratio + 0.4 * delta + colorless_bonus
return _clamp(blended)
def _mechanic_affinity(
primary: Mapping[str, object],
candidate: Mapping[str, object],
mode: PartnerMode,
context: PartnerSuggestionContext,
) -> tuple[float, list[str], list[tuple[str, float]]]:
primary_meta = _partner_meta(primary)
candidate_meta = _partner_meta(candidate)
primary_name = _commander_name(primary)
candidate_name = _commander_name(candidate)
notes: list[str] = []
penalties: list[tuple[str, float]] = []
score = 0.0
if mode is PartnerMode.PARTNER_WITH:
partner_with = {
_normalize_token(name)
for name in primary_meta.get("partner_with", [])
}
if partner_with and _normalize_token(candidate_name) in partner_with:
score = 1.0
notes.append("partner_with_match")
else:
penalties.append(("missing_partner_with_link", 0.9))
elif mode is PartnerMode.BACKGROUND:
if candidate_meta.get("is_background") and primary_meta.get("supports_backgrounds"):
score = 0.9
notes.append("background_compatible")
else:
if not candidate_meta.get("is_background"):
penalties.append(("candidate_not_background", 1.0))
if not primary_meta.get("supports_backgrounds"):
penalties.append(("primary_cannot_use_background", 1.0))
elif mode is PartnerMode.DOCTOR_COMPANION:
primary_is_doctor = bool(primary_meta.get("is_doctor"))
primary_is_companion = bool(primary_meta.get("is_doctors_companion"))
candidate_is_doctor = bool(candidate_meta.get("is_doctor"))
candidate_is_companion = bool(candidate_meta.get("is_doctors_companion"))
if primary_is_doctor and candidate_is_companion:
score = 1.0
notes.append("doctor_companion_match")
elif primary_is_companion and candidate_is_doctor:
score = 1.0
notes.append("doctor_companion_match")
else:
penalties.append(("doctor_pairing_illegal", 1.0))
else: # Partner-style default
if primary_meta.get("has_partner") and candidate_meta.get("has_partner"):
score = 0.6
notes.append("shared_partner_keyword")
else:
penalties.append(("missing_partner_keyword", 1.0))
primary_labels = {
_normalize_token(label)
for label in _sequence(primary_meta, "restricted_partner_labels")
}
candidate_labels = {
_normalize_token(label)
for label in _sequence(candidate_meta, "restricted_partner_labels")
}
shared_labels = primary_labels & candidate_labels
if primary_labels or candidate_labels:
if shared_labels:
score = max(score, 0.85)
notes.append("restricted_label_match")
else:
penalties.append(("restricted_label_mismatch", 0.7))
observed = context.pairing_strength(mode, primary_name, candidate_name)
if observed > 0:
score = max(score, observed)
notes.append("observed_pairing")
return _clamp(score), notes, penalties
def _collect_penalties(
primary: Mapping[str, object],
candidate: Mapping[str, object],
mode: PartnerMode,
extra: Iterable[tuple[str, float]],
) -> tuple[float, list[str]]:
penalties: list[tuple[str, float]] = list(extra)
themes_primary_raw = _sequence(primary, "themes")
themes_candidate_raw = _sequence(candidate, "themes")
themes_primary = _theme_sequence(primary)
themes_candidate = _theme_sequence(candidate)
if (not themes_primary or not themes_candidate) and (not themes_primary_raw or not themes_candidate_raw):
penalties.append(("missing_theme_metadata", 0.5))
if mode is PartnerMode.PARTNER_WITH:
partner_with = {
_normalize_token(name)
for name in _sequence(primary.get("partner", {}), "partner_with")
}
if not partner_with:
penalties.append(("primary_missing_partner_with", 0.7))
colors_candidate = set(_sequence(candidate, "color_identity"))
if len(colors_candidate) >= 4:
penalties.append(("candidate_color_spread", 0.25))
total = 0.0
reasons: list[str] = []
for reason, magnitude in penalties:
if magnitude <= 0:
continue
total += magnitude
reasons.append(reason)
return _clamp(total), reasons

View file

@ -10,6 +10,8 @@ __all__ = [
"get_mdfc_metrics",
"record_theme_summary",
"get_theme_metrics",
"record_partner_summary",
"get_partner_metrics",
]
@ -34,6 +36,14 @@ _theme_metrics: Dict[str, Any] = {
_user_theme_counter: Counter[str] = Counter()
_user_theme_labels: Dict[str, str] = {}
_partner_metrics: Dict[str, Any] = {
"total_pairs": 0,
"mode_counts": {},
"last_summary": None,
"last_updated": None,
"last_updated_iso": None,
}
def _to_int(value: Any) -> int:
try:
@ -143,6 +153,15 @@ def _reset_metrics_for_test() -> None:
)
_user_theme_counter.clear()
_user_theme_labels.clear()
_partner_metrics.update(
{
"total_pairs": 0,
"mode_counts": {},
"last_summary": None,
"last_updated": None,
"last_updated_iso": None,
}
)
def _sanitize_theme_list(values: Iterable[Any]) -> list[str]:
@ -239,3 +258,57 @@ def get_theme_metrics() -> Dict[str, Any]:
"last_updated": _theme_metrics.get("last_updated_iso"),
"top_user_themes": top_user,
}
def record_partner_summary(commander_summary: Dict[str, Any] | None) -> None:
if not isinstance(commander_summary, dict):
return
combined = commander_summary.get("combined")
if not isinstance(combined, dict):
return
mode = str(commander_summary.get("partner_mode") or combined.get("partner_mode") or "none")
primary = commander_summary.get("primary")
secondary = commander_summary.get("secondary")
names = commander_summary.get("names")
color_identity_raw = combined.get("color_identity")
if isinstance(color_identity_raw, (list, tuple)):
colors = [str(c).strip().upper() for c in color_identity_raw if str(c).strip()]
else:
colors = []
entry = {
"primary": primary,
"secondary": secondary,
"names": list(names or []) if isinstance(names, (list, tuple)) else names,
"partner_mode": mode,
"color_identity": colors,
"color_code": combined.get("color_code"),
"color_label": combined.get("color_label"),
"color_sources": combined.get("color_sources"),
"color_delta": combined.get("color_delta"),
"secondary_role": combined.get("secondary_role"),
"secondary_role_label": combined.get("secondary_role_label"),
}
timestamp = time.time()
iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(timestamp))
mode_key = mode.lower() if isinstance(mode, str) else "none"
with _lock:
_partner_metrics["total_pairs"] = int(_partner_metrics.get("total_pairs", 0) or 0) + 1
mode_counts = _partner_metrics.setdefault("mode_counts", {})
mode_counts[mode_key] = int(mode_counts.get(mode_key, 0) or 0) + 1
_partner_metrics["last_summary"] = entry
_partner_metrics["last_updated"] = timestamp
_partner_metrics["last_updated_iso"] = iso
def get_partner_metrics() -> Dict[str, Any]:
with _lock:
return {
"total_pairs": int(_partner_metrics.get("total_pairs", 0) or 0),
"mode_counts": dict(_partner_metrics.get("mode_counts", {})),
"last_summary": _partner_metrics.get("last_summary"),
"last_updated": _partner_metrics.get("last_updated_iso"),
}