mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat: Added Partners, Backgrounds, and related variation selections to commander building.
This commit is contained in:
parent
641b305955
commit
d416c9b238
65 changed files with 11835 additions and 691 deletions
262
code/deck_builder/background_loader.py
Normal file
262
code/deck_builder/background_loader.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]
|
||||
|
||||
|
|
|
|||
134
code/deck_builder/color_identity_utils.py
Normal file
134
code/deck_builder/color_identity_utils.py
Normal 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))
|
||||
325
code/deck_builder/combined_commander.py
Normal file
325
code/deck_builder/combined_commander.py
Normal 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",
|
||||
]
|
||||
287
code/deck_builder/partner_background_utils.py
Normal file
287
code/deck_builder/partner_background_utils.py
Normal 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)
|
||||
426
code/deck_builder/partner_selection.py
Normal file
426
code/deck_builder/partner_selection.py
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
"""Helpers for applying partner/background inputs to a deck build."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from exceptions import CommanderPartnerError
|
||||
from deck_builder.background_loader import load_background_cards
|
||||
from deck_builder.combined_commander import (
|
||||
CombinedCommander,
|
||||
PartnerMode,
|
||||
build_combined_commander,
|
||||
)
|
||||
from logging_util import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
try: # Optional pandas import for type checking without heavy dependency at runtime.
|
||||
import pandas as _pd # type: ignore
|
||||
except Exception: # pragma: no cover - tests provide DataFrame-like objects.
|
||||
_pd = None # type: ignore
|
||||
|
||||
__all__ = ["apply_partner_inputs", "normalize_lookup_name"]
|
||||
|
||||
|
||||
def normalize_lookup_name(value: str | None) -> str:
|
||||
"""Normalize a commander/background name for case-insensitive lookups."""
|
||||
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
|
||||
def apply_partner_inputs(
|
||||
builder: Any,
|
||||
*,
|
||||
primary_name: str,
|
||||
secondary_name: str | None = None,
|
||||
background_name: str | None = None,
|
||||
feature_enabled: bool = False,
|
||||
background_catalog: Any | None = None,
|
||||
selection_source: str | None = None,
|
||||
) -> CombinedCommander | None:
|
||||
"""Apply partner/background inputs to a builder if the feature is enabled.
|
||||
|
||||
Args:
|
||||
builder: Deck builder instance exposing ``load_commander_data``.
|
||||
primary_name: The selected primary commander name.
|
||||
secondary_name: Optional partner/partner-with commander name.
|
||||
background_name: Optional background name.
|
||||
feature_enabled: Whether partner mechanics are enabled for this run.
|
||||
background_catalog: Optional override for background catalog (testing).
|
||||
selection_source: Optional tag describing how the selection was made (e.g., "suggestion").
|
||||
|
||||
Returns:
|
||||
CombinedCommander when a partner/background pairing is produced; ``None``
|
||||
when the feature is disabled or no secondary/background inputs are given.
|
||||
|
||||
Raises:
|
||||
CommanderPartnerError: If inputs are invalid or commanders cannot be
|
||||
combined under rules constraints.
|
||||
"""
|
||||
|
||||
if not feature_enabled:
|
||||
return None
|
||||
|
||||
secondary_name = _coerce_name(secondary_name)
|
||||
background_name = _coerce_name(background_name)
|
||||
|
||||
if not primary_name:
|
||||
return None
|
||||
|
||||
clean_selection_source = (selection_source or "").strip().lower() or None
|
||||
|
||||
if secondary_name and background_name:
|
||||
raise CommanderPartnerError(
|
||||
"Provide either 'secondary_commander' or 'background', not both.",
|
||||
details={
|
||||
"primary": primary_name,
|
||||
"secondary_commander": secondary_name,
|
||||
"background": background_name,
|
||||
},
|
||||
)
|
||||
|
||||
if not secondary_name and not background_name:
|
||||
return None
|
||||
|
||||
commander_df = builder.load_commander_data()
|
||||
primary_row = _find_commander_row(commander_df, primary_name)
|
||||
if primary_row is None:
|
||||
raise CommanderPartnerError(
|
||||
f"Primary commander not found: {primary_name}",
|
||||
details={"commander": primary_name},
|
||||
)
|
||||
|
||||
primary_source = _row_to_commander_source(primary_row)
|
||||
|
||||
if background_name:
|
||||
catalog = background_catalog or load_background_cards()
|
||||
background_card = _lookup_background_card(catalog, background_name)
|
||||
if background_card is None:
|
||||
raise CommanderPartnerError(
|
||||
f"Background not found: {background_name}",
|
||||
details={"background": background_name},
|
||||
)
|
||||
combined = build_combined_commander(primary_source, background_card, PartnerMode.BACKGROUND)
|
||||
_log_partner_selection(
|
||||
combined,
|
||||
primary_source=primary_source,
|
||||
secondary_source=None,
|
||||
background_source=background_card,
|
||||
selection_source=clean_selection_source,
|
||||
)
|
||||
return combined
|
||||
|
||||
# Partner/Partner With flow
|
||||
secondary_row = _find_commander_row(commander_df, secondary_name)
|
||||
if secondary_row is None:
|
||||
raise CommanderPartnerError(
|
||||
f"Secondary commander not found: {secondary_name}",
|
||||
details={"secondary_commander": secondary_name},
|
||||
)
|
||||
|
||||
secondary_source = _row_to_commander_source(secondary_row)
|
||||
errors: list[CommanderPartnerError] = []
|
||||
combined: CombinedCommander | None = None
|
||||
for mode in (PartnerMode.PARTNER_WITH, PartnerMode.DOCTOR_COMPANION, PartnerMode.PARTNER):
|
||||
try:
|
||||
combined = build_combined_commander(primary_source, secondary_source, mode)
|
||||
break
|
||||
except CommanderPartnerError as exc:
|
||||
errors.append(exc)
|
||||
|
||||
if combined is not None:
|
||||
_log_partner_selection(
|
||||
combined,
|
||||
primary_source=primary_source,
|
||||
secondary_source=secondary_source,
|
||||
background_source=None,
|
||||
selection_source=clean_selection_source,
|
||||
)
|
||||
return combined
|
||||
|
||||
if errors:
|
||||
raise errors[-1]
|
||||
raise CommanderPartnerError("Unable to combine commanders with provided inputs.")
|
||||
|
||||
|
||||
def _coerce_name(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _log_partner_selection(
|
||||
combined: CombinedCommander,
|
||||
*,
|
||||
primary_source: Any,
|
||||
secondary_source: Any | None,
|
||||
background_source: Any | None,
|
||||
selection_source: str | None = None,
|
||||
) -> None:
|
||||
mode_value = combined.partner_mode.value if isinstance(combined.partner_mode, PartnerMode) else str(combined.partner_mode)
|
||||
secondary_role = _secondary_role_for_mode(combined.partner_mode)
|
||||
|
||||
combined_colors = list(combined.color_identity or ())
|
||||
primary_colors = list(combined.primary_color_identity or _safe_colors_from_source(primary_source))
|
||||
if secondary_source is not None:
|
||||
secondary_colors = list(combined.secondary_color_identity or _safe_colors_from_source(secondary_source))
|
||||
else:
|
||||
secondary_colors = list(_safe_colors_from_source(background_source))
|
||||
|
||||
color_delta = {
|
||||
"added": [color for color in combined_colors if color not in primary_colors],
|
||||
"removed": [color for color in primary_colors if color not in combined_colors],
|
||||
"primary": primary_colors,
|
||||
"secondary": secondary_colors,
|
||||
}
|
||||
|
||||
primary_description = _describe_source(primary_source)
|
||||
secondary_description = _describe_source(secondary_source)
|
||||
background_description = _describe_source(background_source)
|
||||
|
||||
commanders = {
|
||||
"primary": combined.primary_name,
|
||||
"secondary": combined.secondary_name,
|
||||
"background": (background_description or {}).get("display_name"),
|
||||
}
|
||||
sources = {
|
||||
"primary": primary_description,
|
||||
"secondary": secondary_description,
|
||||
"background": background_description,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"mode": mode_value,
|
||||
"secondary_role": secondary_role,
|
||||
"primary_name": commanders["primary"],
|
||||
"secondary_name": commanders["secondary"],
|
||||
"background_name": commanders["background"],
|
||||
"commanders": commanders,
|
||||
"color_identity": combined_colors,
|
||||
"colors_after": combined_colors,
|
||||
"colors_before": primary_colors,
|
||||
"color_code": combined.color_code,
|
||||
"color_label": combined.color_label,
|
||||
"color_delta": color_delta,
|
||||
"primary_source": sources["primary"],
|
||||
"secondary_source": sources["secondary"],
|
||||
"background_source": sources["background"],
|
||||
"sources": sources,
|
||||
}
|
||||
|
||||
if selection_source:
|
||||
payload["selection_source"] = selection_source
|
||||
|
||||
logger.info(
|
||||
"partner_mode_selected",
|
||||
extra={
|
||||
"event": "partner_mode_selected",
|
||||
"payload": payload,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _secondary_role_for_mode(mode: PartnerMode) -> str:
|
||||
if mode is PartnerMode.BACKGROUND:
|
||||
return "background"
|
||||
if mode is PartnerMode.DOCTOR_COMPANION:
|
||||
return "companion"
|
||||
if mode is PartnerMode.PARTNER_WITH:
|
||||
return "partner_with"
|
||||
if mode is PartnerMode.PARTNER:
|
||||
return "partner"
|
||||
return "secondary"
|
||||
|
||||
|
||||
def _safe_colors_from_source(source: Any | None) -> list[str]:
|
||||
if source is None:
|
||||
return []
|
||||
value = getattr(source, "color_identity", None) or getattr(source, "colors", None)
|
||||
return list(_normalize_color_identity(value))
|
||||
|
||||
|
||||
def _describe_source(source: Any | None) -> dict[str, object] | None:
|
||||
if source is None:
|
||||
return None
|
||||
name = getattr(source, "name", None) or getattr(source, "display_name", None)
|
||||
display_name = getattr(source, "display_name", None) or name
|
||||
partner_with = getattr(source, "partner_with", None)
|
||||
if partner_with is None:
|
||||
partner_with = getattr(source, "partnerWith", None)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"color_identity": _safe_colors_from_source(source),
|
||||
"themes": list(getattr(source, "themes", ()) or getattr(source, "theme_tags", ()) or []),
|
||||
"partner_with": list(partner_with or ()),
|
||||
}
|
||||
|
||||
|
||||
def _find_commander_row(df: Any, name: str | None):
|
||||
if name is None:
|
||||
return None
|
||||
target = normalize_lookup_name(name)
|
||||
if not target:
|
||||
return None
|
||||
|
||||
if _pd is not None and isinstance(df, _pd.DataFrame): # type: ignore
|
||||
columns = [col for col in ("name", "faceName") if col in df.columns]
|
||||
for col in columns:
|
||||
series = df[col].astype(str).str.casefold()
|
||||
matches = df[series == target]
|
||||
if not matches.empty:
|
||||
return matches.iloc[0]
|
||||
return None
|
||||
|
||||
# Fallback for DataFrame-like sequences
|
||||
for row in getattr(df, "itertuples", lambda index=False: [])(): # pragma: no cover - defensive
|
||||
for attr in ("name", "faceName"):
|
||||
value = getattr(row, attr, None)
|
||||
if normalize_lookup_name(value) == target:
|
||||
return getattr(df, "loc", lambda *_: row)(row.Index) if hasattr(row, "Index") else row
|
||||
return None
|
||||
|
||||
|
||||
def _row_to_commander_source(row: Any) -> SimpleNamespace:
|
||||
themes = _normalize_string_sequence(row.get("themeTags"))
|
||||
partner_with = _normalize_string_sequence(
|
||||
row.get("partnerWith")
|
||||
or row.get("partner_with")
|
||||
or row.get("partnerNames")
|
||||
or row.get("partner_names")
|
||||
)
|
||||
|
||||
return SimpleNamespace(
|
||||
name=_safe_str(row.get("name")),
|
||||
display_name=_safe_str(row.get("faceName")) or _safe_str(row.get("name")),
|
||||
color_identity=_normalize_color_identity(row.get("colorIdentity")),
|
||||
colors=_normalize_color_identity(row.get("colors")),
|
||||
themes=themes,
|
||||
theme_tags=themes,
|
||||
raw_tags=themes,
|
||||
partner_with=partner_with,
|
||||
oracle_text=_safe_str(row.get("text") or row.get("oracleText")),
|
||||
type_line=_safe_str(row.get("type") or row.get("type_line")),
|
||||
supports_backgrounds=_normalize_bool(row.get("supportsBackgrounds") or row.get("supports_backgrounds")),
|
||||
is_partner=_normalize_bool(row.get("isPartner") or row.get("is_partner")),
|
||||
is_background=_normalize_bool(row.get("isBackground") or row.get("is_background")),
|
||||
is_doctor=_normalize_bool(row.get("isDoctor") or row.get("is_doctor")),
|
||||
is_doctors_companion=_normalize_bool(row.get("isDoctorsCompanion") or row.get("is_doctors_companion")),
|
||||
)
|
||||
|
||||
|
||||
def _lookup_background_card(catalog: Any, name: str) -> Any | None:
|
||||
lowered = normalize_lookup_name(name)
|
||||
|
||||
getter = getattr(catalog, "get", None)
|
||||
if callable(getter):
|
||||
result = getter(name)
|
||||
if result is None:
|
||||
result = getter(lowered)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
entries = getattr(catalog, "entries", None)
|
||||
if entries is not None:
|
||||
for entry in entries:
|
||||
display = normalize_lookup_name(getattr(entry, "display_name", None))
|
||||
if display == lowered:
|
||||
return entry
|
||||
raw = normalize_lookup_name(getattr(entry, "name", None))
|
||||
if raw == lowered:
|
||||
return entry
|
||||
slug = normalize_lookup_name(getattr(entry, "slug", None))
|
||||
if slug == lowered:
|
||||
return entry
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_color_identity(value: Any) -> tuple[str, ...]:
|
||||
tokens = _normalize_string_sequence(value)
|
||||
result: list[str] = []
|
||||
for token in tokens:
|
||||
if len(token) > 1 and "," not in token and " " not in token:
|
||||
if all(ch in "WUBRGC" for ch in token):
|
||||
result.extend(ch for ch in token)
|
||||
else:
|
||||
result.append(token)
|
||||
else:
|
||||
result.append(token)
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for item in result:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
ordered.append(item)
|
||||
return tuple(ordered)
|
||||
|
||||
|
||||
def _normalize_string_sequence(value: Any) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return tuple()
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
items = list(value)
|
||||
else:
|
||||
text = _safe_str(value)
|
||||
if not text:
|
||||
return tuple()
|
||||
try:
|
||||
parsed = ast.literal_eval(text)
|
||||
except Exception: # pragma: no cover - non literal values handled below
|
||||
parsed = None
|
||||
if isinstance(parsed, (list, tuple, set)):
|
||||
items = list(parsed)
|
||||
elif ";" in text:
|
||||
items = [part.strip() for part in text.split(";")]
|
||||
elif "," in text:
|
||||
items = [part.strip() for part in text.split(",")]
|
||||
else:
|
||||
items = [text]
|
||||
collected: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
token = _safe_str(item)
|
||||
if not token:
|
||||
continue
|
||||
key = token.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
collected.append(token)
|
||||
return tuple(collected)
|
||||
|
||||
|
||||
def _normalize_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value in (0, 1):
|
||||
return bool(value)
|
||||
text = _safe_str(value).casefold()
|
||||
if not text:
|
||||
return False
|
||||
if text in {"1", "true", "t", "yes", "on"}:
|
||||
return True
|
||||
if text in {"0", "false", "f", "no", "off"}:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _safe_str(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float) and value != value: # NaN check
|
||||
return ""
|
||||
text = str(value)
|
||||
if "\\r\\n" in text or "\\n" in text or "\\r" in text:
|
||||
text = (
|
||||
text.replace("\\r\\n", "\n")
|
||||
.replace("\\r", "\n")
|
||||
.replace("\\n", "\n")
|
||||
)
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
return text.strip()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
662
code/deck_builder/suggestions.py
Normal file
662
code/deck_builder/suggestions.py
Normal 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
|
||||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue